Compare commits

...

1 Commits

Author SHA1 Message Date
Superset Dev
a256eb04cd feat(mobile): consumption-only mobile experience behind MOBILE_CONSUMPTION_MODE
Adds an opt-in, consumption-only mobile experience (feature flag
MOBILE_CONSUMPTION_MODE, default off, @lifecycle: development):

- Dashboards: charts stacked full-width with real plugin dimensions
  (ChartHolder reports full column count on mobile; heights capped to
  the viewport minus chrome), sticky swipeable tab bars with gradient
  overflow affordances, filter bar in a drawer (FilterBar mobileMode),
  compact header (title scrolls away; edit/publish/fave/refresh controls
  hidden; dashboard info moved into the kebab menu)
- Dashboard list: forced card view, full-width cards, search/filters and
  sort in a drawer (single FilterControls instance)
- Home: dashboards-only Recents, compact empty states, desktop-only
  sections hidden
- Navigation: hamburger drawer (dashboards, theme/language, user
  info/logout with row-tap navigation)
- Route guarding: routes declare mobileSupported in routes.tsx;
  everything else renders a MobileUnsupported screen; viewport growth
  unblocks automatically (useIsMobile subscribes to matchMedia only when
  the flag is on, so flag-off deployments have zero render delta)
- Serves a viewport meta tag (flag-gated) so mobile browsers lay out at
  device width instead of the ~980px legacy viewport; exposes
  is_feature_enabled to Jinja via the common context processor
- User docs (using-superset/mobile-experience.mdx) with a Playwright
  screenshot generator following the docs:screenshots pattern
- Docker dev config enables the flag; jest + Playwright coverage
  throughout

Squashed from the iterative mobile-dashboard-support history (preserved
at backup/mobile-pre-rebase-2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:30:58 -07:00
63 changed files with 2921 additions and 195 deletions

View File

@@ -155,6 +155,17 @@ jobs:
INCLUDE_EMBEDDED: "true"
with:
run: playwright-run "${{ matrix.app_root }}" embedded
- name: Run Playwright (Mobile Tests)
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
# Scoped to this step for the same reason as the embedded flags
# above: the mobile consumption mode should not alter Flask's
# configuration for the required desktop test steps.
SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE: "true"
INCLUDE_MOBILE: "true"
with:
run: playwright-run "${{ matrix.app_root }}" mobile/
- name: Set safe app root
if: failure()
id: set-safe-app-root

View File

@@ -110,6 +110,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

@@ -0,0 +1,89 @@
---
title: Mobile Experience
sidebar_position: 7
version: 1
---
import useBaseUrl from "@docusaurus/useBaseUrl";
# Mobile Experience
Superset ships an optional, consumption-only mobile experience for viewing
dashboards on phones and other small screens. When enabled, screens below
768px wide get a layout built for touch: dashboards render their charts
stacked full-width, navigation collapses into a drawer, and dashboard
filters open in a slide-out panel.
The mobile experience is **read-only by design**. It is aimed at consumers
of analytics — people checking a dashboard from a phone — not at dashboard
authors. Authoring surfaces (chart builder, SQL Lab, dataset management,
and administrative screens) remain desktop-only.
## Enabling the mobile experience
The mobile experience is gated behind the `MOBILE_CONSUMPTION_MODE` feature
flag, which is off by default. Enable it in your `superset_config.py`:
```python
FEATURE_FLAGS = {
"MOBILE_CONSUMPTION_MODE": True,
}
```
With the flag disabled, Superset renders identically at every screen size,
and phones display the desktop layout scaled down (the pre-existing
behavior). The flag also controls whether Superset serves a viewport meta
tag, which is required for mobile browsers to apply the responsive layout
at their native width.
## What works on mobile
| Area | Mobile behavior |
| --- | --- |
| **Dashboards** | Charts stack vertically at full width, sized to the screen. Tab bars are sticky and swipeable. Native filters open in a drawer via the filter icon in the header. |
| **Dashboard list** | Card view with full-width cards; search and filters open in a drawer. |
| **Home** | Recents (dashboards only) and dashboard cards; desktop-only sections are hidden. |
| **Navigation** | A hamburger menu opens a drawer with links to dashboards, theme and language selection, and user info/logout. |
<div style={{display: 'flex', gap: '1rem', flexWrap: 'wrap'}}>
<img src={useBaseUrl("/img/screenshots/mobile/mobile_dashboard.jpg")} alt="A dashboard on mobile with charts stacked full width" width="260" />
<img src={useBaseUrl("/img/screenshots/mobile/mobile_filter_drawer.jpg")} alt="The dashboard filter drawer on mobile" width="260" />
<img src={useBaseUrl("/img/screenshots/mobile/mobile_dashboard_list.jpg")} alt="The dashboard list in card view on mobile" width="260" />
</div>
<div style={{display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '1rem'}}>
<img src={useBaseUrl("/img/screenshots/mobile/mobile_home.jpg")} alt="The Superset home page on mobile" width="260" />
<img src={useBaseUrl("/img/screenshots/mobile/mobile_nav_drawer.jpg")} alt="The mobile navigation drawer" width="260" />
<img src={useBaseUrl("/img/screenshots/mobile/mobile_unsupported.jpg")} alt="The screen shown for views that are not available on mobile" width="260" />
</div>
## What doesn't work on mobile
Everything not listed above shows a friendly "This view isn't available on
mobile" screen with shortcuts back to dashboards and the home page. That
includes:
- Chart builder (Explore) and chart-level links — chart titles on
dashboards are plain text on mobile, and chart entries are filtered out
of the home page's Recents feed
- SQL Lab and query history
- Creating or editing dashboards, charts, datasets, and databases
- List views other than dashboards (charts, datasets, saved queries, etc.)
- Administrative and settings screens
Editing controls are also removed from the screens that *are* supported:
the dashboard header hides the edit, publish, and favorite controls, and
dashboard/chart kebab menus are reduced to view-oriented actions.
If a device crosses the 768px threshold — for example, rotating a tablet
to landscape or resizing a window — the full desktop experience becomes
available immediately.
## Notes for operators
- The flag is deployment-wide; there is no per-role or per-user targeting.
- Dashboard permalinks and links shared from desktop resolve normally on
mobile as long as they point at dashboards.
- Embedded dashboards are unaffected: the embedded SDK controls its own
layout, and the viewport meta tag is only interpreted by the top-level
page.

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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

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,20 @@ 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 +124,7 @@ export type PageHeaderWithActionsProps = {
showFaveStar: boolean;
showMenuDropdown?: boolean;
faveStarProps: FaveStarProps;
leftPanelItems?: ReactNode;
titlePanelAdditionalItems: ReactNode;
rightPanelAdditionalItems: ReactNode;
additionalActionsMenu: ReactElement;
@@ -126,6 +142,7 @@ export const PageHeaderWithActions = memo(
certificatiedBadgeProps,
showFaveStar,
faveStarProps,
leftPanelItems,
titlePanelAdditionalItems,
rightPanelAdditionalItems,
additionalActionsMenu,
@@ -136,6 +153,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

@@ -57,6 +57,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

@@ -96,6 +96,7 @@ export default defineConfig({
'**/tests/auth/**/*.spec.ts',
'**/tests/sqllab/**/*.spec.ts',
'**/tests/embedded/**/*.spec.ts',
'**/tests/mobile/**/*.spec.ts',
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
],
use: {
@@ -156,6 +157,23 @@ export default defineConfig({
},
]
: []),
// Mobile consumption-mode tests need the MOBILE_CONSUMPTION_MODE feature
// flag enabled in the Flask backend (the workflow's mobile step sets
// SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE), so they only run when the
// environment opts in. Same strict 'true' check as INCLUDE_EMBEDDED.
...(process.env.INCLUDE_MOBILE?.toLowerCase() === 'true'
? [
{
name: 'chromium-mobile',
testMatch: '**/tests/mobile/**/*.spec.ts',
use: {
browserName: 'chromium' as const,
testIdAttribute: 'data-test',
storageState: 'playwright/.auth/user.json',
},
},
]
: []),
],
// Web server setup - disabled in CI (Flask started separately in workflow)

View File

@@ -0,0 +1,167 @@
/**
* 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 Experience Documentation Screenshot Generator
*
* Captures phone-sized screenshots for the mobile consumption mode docs
* (docs/docs/using-superset/mobile-experience.mdx). Depends on example data
* loaded via `superset load_examples` AND the MOBILE_CONSUMPTION_MODE
* feature flag being enabled in the target environment:
*
* FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}
*
* Run locally:
* cd superset-frontend
* PLAYWRIGHT_BASE_URL=http://localhost:8088 PLAYWRIGHT_ADMIN_PASSWORD=admin npm run docs:screenshots
*
* Screenshots are saved under docs/static/img/screenshots/mobile/.
*/
import path from 'path';
import { Page, test, expect } from '@playwright/test';
import { URL } from '../../utils/urls';
const MOBILE_SCREENSHOTS_DIR = path.resolve(
__dirname,
'../../../../docs/static/img/screenshots/mobile',
);
// iPhone 12-class viewport; 2x scale factor for crisp docs images
test.use({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 2,
hasTouch: true,
});
/**
* Waits for animations and async renders to settle before taking a
* screenshot. ECharts entry animations, drawer transitions, and image
* lazy-loading require a short pause that can't be expressed as a
* deterministic wait condition.
*/
async function settle(page: Page, ms = 1000): Promise<void> {
await page.waitForTimeout(ms);
}
/**
* Opens the Sales Dashboard (from example data) at phone size and waits for
* the stacked charts to finish rendering.
*/
async function openSalesDashboardMobile(page: Page): Promise<void> {
await page.goto(URL.DASHBOARD_LIST);
// Mobile list is card-only; cards navigate on tap (titles are plain
// text, not links, in consumption mode)
const dashboardCard = page.getByText('Sales Dashboard', { exact: true });
await expect(dashboardCard.first()).toBeVisible({ timeout: 15000 });
await dashboardCard.first().click();
await expect(
page.locator('[data-test="dashboard-content-wrapper"]'),
).toBeVisible({ timeout: 30000 });
await expect(
page.locator('.dashboard-component-chart-holder canvas').first(),
).toBeVisible({ timeout: 30000 });
}
test('mobile dashboard screenshot', async ({ page }) => {
await openSalesDashboardMobile(page);
await settle(page, 2000);
await page.screenshot({
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_dashboard.jpg'),
type: 'jpeg',
});
});
test('mobile dashboard filter drawer screenshot', async ({ page }) => {
await openSalesDashboardMobile(page);
const filterTrigger = page.locator('[data-test="mobile-filters-trigger"]');
await expect(filterTrigger).toBeVisible({ timeout: 15000 });
await filterTrigger.click();
// Wait for the drawer and its filter controls to render
await expect(page.locator('.ant-drawer-body')).toBeVisible({
timeout: 10000,
});
await expect(page.locator('[data-test="filter-bar"]')).toBeVisible({
timeout: 10000,
});
// Park the pointer so no hover card is open in the capture
await page.mouse.move(5, 830);
await settle(page);
await page.screenshot({
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_filter_drawer.jpg'),
type: 'jpeg',
});
});
test('mobile dashboard list screenshot', async ({ page }) => {
await page.goto(URL.DASHBOARD_LIST);
// Card view is forced on mobile; wait for cards to render
await expect(page.locator('[data-test="styled-card"]').first()).toBeVisible({
timeout: 15000,
});
await settle(page);
await page.screenshot({
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_dashboard_list.jpg'),
type: 'jpeg',
});
});
test('mobile home screenshot', async ({ page }) => {
await page.goto(URL.WELCOME);
await expect(page.getByText('Recents')).toBeVisible({ timeout: 15000 });
await settle(page, 2000);
await page.screenshot({
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_home.jpg'),
type: 'jpeg',
});
});
test('mobile navigation drawer screenshot', async ({ page }) => {
await page.goto(URL.WELCOME);
await expect(page.getByText('Recents')).toBeVisible({ timeout: 15000 });
const menuButton = page.getByRole('button', { name: 'Menu' });
await expect(menuButton).toBeVisible({ timeout: 10000 });
await menuButton.click();
await expect(page.locator('.ant-drawer-body')).toBeVisible({
timeout: 10000,
});
await expect(page.getByText('Dashboards').first()).toBeVisible();
await settle(page);
await page.screenshot({
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_nav_drawer.jpg'),
type: 'jpeg',
});
});
test('mobile unsupported route screenshot', async ({ page }) => {
await page.goto(URL.SQLLAB);
await expect(
page.getByText("This view isn't available on mobile"),
).toBeVisible({ timeout: 15000 });
await settle(page);
await page.screenshot({
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_unsupported.jpg'),
type: 'jpeg',
});
});

View File

@@ -0,0 +1,325 @@
/**
* 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; the table
// view must never render on mobile
if (cardCount > 0) {
await expect(cards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
} else {
await expect(page.locator('[data-test="empty-state"]')).toBeVisible({
timeout: TIMEOUT.PAGE_LOAD,
});
}
await expect(page.locator('[data-test="listview-table"]')).toHaveCount(0);
});
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,192 @@
/**
* 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 }) => {
await page.goto('/');
});
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('unsupported screen offers no bypass', async ({ page }) => {
// The "Continue anyway" bypass was removed: desktop views are unusable
// at phone width, and growing the viewport unblocks routes automatically
await page.goto(URL.CHART_LIST);
await expect(
page.getByText("This view isn't available on mobile"),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(page.getByText('Continue anyway')).toHaveCount(0);
});
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,150 @@
/**
* 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 },
};

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: _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: _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: _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: _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: _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: _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: _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: _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: _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,14 +506,16 @@ export function ListView<T extends object = any>({
updateFilterValue={applyFilterValue}
/>
)}
{viewMode === 'card' && cardSortSelectOptions && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
{filterable && (
{viewMode === 'card' &&
cardSortSelectOptions &&
!setMobileFiltersOpen && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
{filterable && !setMobileFiltersOpen && (
<Tooltip
title={!hasActiveFilters ? t('No filters applied') : undefined}
>
@@ -659,6 +702,40 @@ 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}
/>
)}
<ClearAllButton
type="button"
disabled={!hasActiveFilters}
onClick={() => filterControlsRef.current?.clearFilters()}
>
{t('Clear all')}
</ClearAllButton>
</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,64 @@
/**
* 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 '.';
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(() => {
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();
});

View File

@@ -0,0 +1,52 @@
/**
* 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 } from 'react';
import { useIsMobile } from 'src/hooks/useIsMobile';
import MobileUnsupported 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. Growing the viewport past the
* breakpoint unblocks the route automatically.
*/
function MobileRouteGuard({
children,
mobileSupported,
}: MobileRouteGuardProps) {
const isMobile = useIsMobile();
if (!isMobile || mobileSupported) {
return <>{children}</>;
}
return <MobileUnsupported />;
}
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 { isMobileConsumptionEnabled, useIsMobile } from 'src/hooks/useIsMobile';
import ResizableSidebar from 'src/components/ResizableSidebar';
import {
BUILDER_SIDEPANEL_WIDTH,
@@ -101,6 +102,19 @@ const StyledHeader = styled.div<{ filterBarWidth: number }>`
z-index: 99;
max-width: calc(100vw - ${filterBarWidth}px);
/* Mobile consumption mode: let the dashboard title scroll away and keep
only the tab bar sticky. A pinned title would sit underneath the
higher-z sticky tabs, leaving its bottom edge (kebab button) peeking
out below the tab bar. */
${
isMobileConsumptionEnabled() &&
css`
@media (max-width: ${theme.screenSMMax}px) {
position: relative;
}
`
}
.empty-droptarget {
min-height: ${theme.sizeUnit * 4}px;
}
@@ -372,6 +386,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 +478,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 +497,7 @@ const DashboardBuilder = () => {
const draggableStyle = useMemo(
() => ({
marginLeft:
!isNotMobile ||
dashboardFiltersOpen ||
editMode ||
!nativeFiltersEnabled ||
@@ -488,6 +506,7 @@ const DashboardBuilder = () => {
: -32,
}),
[
isNotMobile,
dashboardFiltersOpen,
editMode,
filterBarOrientation,
@@ -519,7 +538,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 +560,15 @@ const DashboardBuilder = () => {
)}
</>
),
[hideDashboardHeader, showFilterBar, filterBarOrientation, isReport],
[
hideDashboardHeader,
isNotMobile,
nativeFiltersEnabled,
hasFilters,
showFilterBar,
filterBarOrientation,
isReport,
],
);
const renderDraggableContent = useCallback(
@@ -577,6 +612,8 @@ const DashboardBuilder = () => {
topLevelTabs,
uiConfig.hideTab,
uiConfig.hideNav,
isNotMobile,
theme,
],
);
@@ -739,6 +776,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,22 @@ 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,
@@ -751,7 +765,7 @@ const Header = (): JSX.Element => {
) : (
<div css={actionButtonsStyle}>
{NavExtension && <NavExtension />}
{userCanEdit && !isEmbedded && (
{userCanEdit && !isEmbedded && !isMobile && (
<Button
buttonStyle="secondary"
onClick={handleEnterEditMode}
@@ -779,6 +793,7 @@ const Header = (): JSX.Element => {
handleEnterEditMode,
hasUnsavedChanges,
isEmbedded,
isMobile,
overwriteDashboard,
redoLength,
undoLength,
@@ -815,6 +830,10 @@ const Header = (): JSX.Element => {
userCanCurate,
userCanExport,
isLoading,
isMobile,
isStarred,
isPublished,
saveFaveStar: boundActionCreators.saveFaveStar,
showReportModal,
showPropertiesModal,
showRefreshModal,
@@ -834,6 +853,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={{
@@ -841,7 +875,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 MemoizedFiltersBadge from 'src/dashboard/components/FiltersBadge';
import MemoizedCustomizationsBadge 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,58 @@ 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};
/* breathing room between the tab bar and the first card; padding
(not margin) so the gap is part of the opaque sticky bar */
padding-bottom: ${theme.sizeUnit * 2}px;
}
/* Scrollability affordance: fade the clipped edge with a
theme-colored gradient. antd toggles the ping classes when tabs
overflow on that side; restyle its shadow elements as gradients,
which read much better than the default shadows on dark themes. */
.ant-tabs-nav-wrap:before,
.ant-tabs-nav-wrap:after {
width: ${theme.sizeUnit * 10}px;
box-shadow: none !important;
pointer-events: none;
}
.ant-tabs-nav-wrap-ping-right:after {
background: linear-gradient(
to right,
transparent,
${theme.colorBgContainer}
);
opacity: 1;
}
.ant-tabs-nav-wrap-ping-left:before {
background: linear-gradient(
to left,
transparent,
${theme.colorBgContainer}
);
opacity: 1;
}
/* 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

@@ -680,6 +680,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

@@ -38,6 +38,7 @@ import { SubjectPile } from 'src/features/subjects/SubjectPile';
import { KebabMenuButton } from 'src/components';
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { useIsMobile } from 'src/hooks/useIsMobile';
interface DashboardCardProps {
isChart?: boolean;
@@ -67,6 +68,7 @@ function DashboardCard({
onDelete,
}: DashboardCardProps) {
const userId = user?.userId;
const isMobile = useIsMobile();
const history = useHistory();
const canEdit = hasPerm('can_write');
@@ -212,10 +214,12 @@ function DashboardCard({
isStarred={favoriteStatus}
/>
)}
<KebabMenuButton
menuItems={menuItems}
dataTest="dashboard-card-menu"
/>
{!isMobile && (
<KebabMenuButton
menuItems={menuItems}
dataTest="dashboard-card-menu"
/>
)}
</ListViewCard.Actions>
}
/>

View File

@@ -32,6 +32,7 @@ import {
} from 'src/views/CRUD/utils';
import { Chart } from 'src/types/Chart';
import { Icons } from '@superset-ui/core/components/Icons';
import { useIsMobile } from 'src/hooks/useIsMobile';
import SubMenu from './SubMenu';
import EmptyState from './EmptyState';
import { WelcomeTable, RecentActivity } from './types';
@@ -70,6 +71,12 @@ const Styles = styled.div`
const UNTITLED = t('[Untitled]');
const UNKNOWN_TIME = t('Unknown');
// Dashboards are the only activity entities viewable in the mobile
// consumption experience; charts and saved queries link to unsupported views
const isDashboardEntity = (entity: ActivityObject) =>
'dashboard_title' in entity ||
('item_type' in entity && entity.item_type === 'dashboard');
const getEntityTitle = (entity: ActivityObject) => {
if ('dashboard_title' in entity) return entity.dashboard_title || UNTITLED;
if ('slice_name' in entity) return entity.slice_name || UNTITLED;
@@ -121,6 +128,7 @@ export default function ActivityTable({
}: ActivityProps) {
const [editedCards, setEditedCards] = useState<ActivityData[]>();
const [isFetchingEditedCards, setIsFetchingEditedCards] = useState(false);
const isMobile = useIsMobile();
useEffect(() => {
let isMounted = true;
@@ -174,12 +182,16 @@ export default function ActivityTable({
},
});
}
const renderActivity = () => {
const activities =
(activeChild === TableTab.Edited
? editedCards
: activityData[activeChild as keyof ActivityData]) ?? [];
return activities.map((entity: ActivityObject) => {
const rawActivities =
(activeChild === TableTab.Edited
? editedCards
: activityData[activeChild as keyof ActivityData]) ?? [];
const activities = isMobile
? rawActivities.filter(isDashboardEntity)
: rawActivities;
const renderActivity = () =>
activities.map((entity: ActivityObject) => {
const url = getEntityUrl(entity);
const lastActionOn = getEntityLastActionOn(entity);
return (
@@ -197,7 +209,6 @@ export default function ActivityTable({
</CardStyles>
);
});
};
if ((isFetchingEditedCards && !editedCards) || isFetchingActivityData) {
return <LoadingCards />;
@@ -209,8 +220,7 @@ export default function ActivityTable({
tabs={tabs}
backgroundColor="transparent"
/>
{Number(activityData[activeChild as keyof ActivityData]?.length) > 0 ||
(activeChild === TableTab.Edited && editedCards?.length) ? (
{activities.length > 0 ? (
<CardContainer className="recentCards">
{renderActivity()}
</CardContainer>

View File

@@ -24,6 +24,7 @@ import { TableTab } from 'src/views/CRUD/types';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { navigateTo } from 'src/utils/navigationUtils';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { WelcomeTable } from './types';
const EmptyContainer = styled.div`
@@ -76,12 +77,19 @@ export interface EmptyStateProps {
}
export default function EmptyState({ tableName, tab }: EmptyStateProps) {
const isMobile = useIsMobile();
const getActionButton = () => {
if (tableName === WelcomeTable.Recents) {
return null;
}
const isFavorite = tab === TableTab.Favorite;
// Creation flows aren't part of the mobile consumption experience;
// only offer the "See all ..." navigation there
if (isMobile && !isFavorite) {
return null;
}
const buttonText = isFavorite
? LABELS.viewAll[tableName]
: LABELS.create[tableName];
@@ -111,7 +119,7 @@ export default function EmptyState({ tableName, tab }: EmptyStateProps) {
<EmptyContainer>
<EmptyStateComponent
image={image}
size="large"
size={isMobile ? 'small' : 'large'}
description={t('Nothing here yet')}
>
{getActionButton()}

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();
// screens.md is undefined on the first render before breakpoints are measured;
@@ -405,7 +407,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"
@@ -421,40 +434,44 @@ export function Menu({
<span>{brand.text}</span>
</StyledBrandText>
)}
<StyledMainNav
mode={isMd ? '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={isMd ? '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={isMd ? 'flex-end' : 'flex-start'}
align={isMd || 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,16 @@ 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 {
ensureAppRoot,
navigateTo,
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';
@@ -119,6 +126,7 @@ const RightMenu = ({
navbarRight,
isFrontendRoute,
environmentTag,
menu,
setQuery,
}: RightMenuProps & {
setQuery: ({
@@ -130,6 +138,8 @@ const RightMenu = ({
}) => void;
}) => {
const theme = useTheme();
const isMobile = useIsMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const user = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
@@ -644,6 +654,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 && (
@@ -704,48 +772,98 @@ 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);
// The reused desktop items navigate via anchors that only
// span their label text, but the drawer's tap target is the
// full menu row — navigate explicitly so row taps work.
if (info.key === 'info' && navbarRight.user_info_url) {
navigateTo(navbarRight.user_info_url);
return;
}
if (info.key === 'logout' && navbarRight.user_logout_url) {
navigateTo(navbarRight.user_logout_url);
return;
}
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

@@ -50,6 +50,15 @@ jest.mock('src/utils/getBootstrapData', () =>
mockUserSubjectsBootstrapData([1]),
);
// 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,
@@ -174,6 +176,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,
);
@@ -833,7 +838,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(
@@ -922,8 +945,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 { UserWithPermissionsAndRoles } 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,95 @@
/**
* 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 useHistory
const mockPush = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
push: mockPush,
}),
}));
beforeEach(() => {
jest.clearAllMocks();
});
const renderComponent = () =>
render(
<MemoryRouter initialEntries={['/chart/list/']}>
<MobileUnsupported />
</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('does not render a Continue anyway bypass', () => {
renderComponent();
expect(screen.queryByText(/Continue anyway/)).not.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/');
});

View File

@@ -0,0 +1,143 @@
/**
* 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 } from 'react-router-dom';
import { t } from '@apache-superset/core/translation';
import { css, useTheme } from '@apache-superset/core/theme';
import { Button } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
/**
* A mobile-friendly page shown when users try to access
* features that aren't supported on mobile devices. Growing the window
* past the mobile breakpoint unblocks the route automatically (useIsMobile
* subscribes to the breakpoint), so no manual bypass is offered.
*/
function MobileUnsupported() {
const theme = useTheme();
const history = useHistory();
const handleViewDashboards = useCallback(() => {
history.push('/dashboard/list/');
}, [history]);
const handleGoHome = useCallback(() => {
history.push('/welcome/');
}, [history]);
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>
</div>
);
}
export default MobileUnsupported;

View File

@@ -35,6 +35,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 MenuWrapper from 'src/features/home/Menu';
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
@@ -97,19 +98,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';
@@ -457,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

@@ -708,6 +708,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

@@ -1063,6 +1063,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
@@ -1072,7 +1073,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 is defined and 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