Compare commits

...

2 Commits

Author SHA1 Message Date
Superset Dev
9da3908bec refactor(mobile): gate consumption mode behind MOBILE_CONSUMPTION_MODE flag, fix chart sizing
Review-pass hardening of the mobile consumption mode:

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 22:48:31 -07:00
47 changed files with 2668 additions and 196 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,326 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { test, expect, devices } from '@playwright/test';
// NOTE: These tests exercise the mobile consumption experience and require
// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target
// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}).
import { TIMEOUT } from '../../utils/constants';
/**
* Mobile dashboard viewing tests verify that dashboards can be viewed
* and interacted with on mobile devices.
*
* These tests assume the World Bank's Health sample dashboard exists.
*/
// Use iPhone 12 viewport for mobile tests
const mobileViewport = devices['iPhone 12'];
test.describe('Mobile Dashboard Viewing', () => {
test.use({
viewport: mobileViewport.viewport,
userAgent: mobileViewport.userAgent,
});
test.beforeEach(async ({ page }) => {
// Navigate to dashboard list to find a dashboard
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
});
test('dashboard list renders in card view on mobile', async ({ page }) => {
// On mobile, dashboard list should show cards, not table
// Look for card elements
const cards = page.locator('[data-test="styled-card"]');
// Should have at least one card if dashboards exist
// (This test may need adjustment based on test data availability)
const cardCount = await cards.count();
// Either cards are visible, or the empty state is shown
if (cardCount > 0) {
await expect(cards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
} else {
// No dashboards - that's OK for the test environment
await expect(
page
.getByText('No dashboards yet')
.or(page.locator('[data-test="listview-table"]')),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
}
});
test('mobile search button appears in dashboard list', async ({ page }) => {
// On mobile, the search/filter button should appear in the header
const searchButton = page
.locator('[aria-label="Search"]')
.or(page.locator('[data-test="mobile-search-button"]'));
// Search button should be visible on mobile
await expect(searchButton.first()).toBeVisible({
timeout: TIMEOUT.PAGE_LOAD,
});
});
test('tapping dashboard card opens the dashboard', async ({ page }) => {
// Find a dashboard card
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
if (cardCount > 0) {
// Click the first card
await cards.first().click();
// Should navigate to dashboard view
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Dashboard should load (look for dashboard content)
await expect(
page
.locator('[data-test="dashboard-content-wrapper"]')
.or(page.locator('.dashboard')),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
} else {
test.skip();
}
});
});
test.describe('Mobile Dashboard Interaction', () => {
test.use({
viewport: mobileViewport.viewport,
userAgent: mobileViewport.userAgent,
});
// Skip this test suite if no dashboards exist
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage({
viewport: mobileViewport.viewport,
userAgent: mobileViewport.userAgent,
});
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
await page.close();
if (cardCount === 0) {
test.skip();
}
});
test('dashboard loads and shows charts on mobile', async ({ page }) => {
// Navigate to dashboard list
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
// Click first dashboard
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
if (cardCount > 0) {
await cards.first().click();
// Wait for dashboard to load
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Dashboard content should be visible
await expect(
page
.locator('[data-test="dashboard-content-wrapper"]')
.or(page.locator('.dashboard')),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// Charts should start loading (look for chart containers)
const chartContainers = page
.locator('[data-test="chart-container"]')
.or(page.locator('.dashboard-chart'));
// Wait for at least one chart to be visible (with timeout)
await expect(chartContainers.first()).toBeVisible({
timeout: TIMEOUT.PAGE_LOAD * 2,
});
}
});
test('dashboard header shows hamburger menu on mobile', async ({ page }) => {
// Navigate to dashboard list
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
// Click first dashboard
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
if (cardCount > 0) {
await cards.first().click();
// Wait for dashboard
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Look for the hamburger menu / more actions button
const menuButton = page
.locator('[data-test="actions-trigger"]')
.or(page.locator('[aria-label="Menu actions trigger"]'));
await expect(menuButton.first()).toBeVisible({
timeout: TIMEOUT.PAGE_LOAD,
});
}
});
test('refresh dashboard works from mobile menu', async ({ page }) => {
// Navigate to dashboard list
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
// Click first dashboard
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
if (cardCount > 0) {
await cards.first().click();
// Wait for dashboard
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Open the actions menu
const menuButton = page
.locator('[data-test="actions-trigger"]')
.or(page.locator('[aria-label="Menu actions trigger"]'));
if ((await menuButton.count()) > 0) {
await menuButton.first().click();
// Look for refresh option
const refreshOption = page.getByText('Refresh dashboard');
if ((await refreshOption.count()) > 0) {
await refreshOption.click();
// Should show success toast or refresh the charts
// This is hard to verify without checking network requests
// Just verify the menu closes and we're still on the dashboard
await page.waitForTimeout(1000);
expect(page.url()).toMatch(/\/dashboard\/(?!list)/);
}
}
}
});
});
test.describe('Mobile Filter Drawer', () => {
test.use({
viewport: mobileViewport.viewport,
userAgent: mobileViewport.userAgent,
});
test('filter button appears on dashboards with filters', async ({ page }) => {
// Navigate to dashboard list
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
// Click first dashboard
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
if (cardCount > 0) {
await cards.first().click();
// Wait for dashboard
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Give filters time to load
await page.waitForTimeout(2000);
// Check for filter button (only visible if dashboard has filters)
const filterButton = page
.locator('[data-test="filter-icon"]')
.or(
page
.locator('[aria-label="Filters"]')
.or(page.locator('.mobile-filter-button')),
);
const filterCount = await filterButton.count();
// The test passes whether filters exist or not
// If filters exist, button should be visible
// If no filters, that's also valid
if (filterCount > 0) {
await expect(filterButton.first()).toBeVisible();
}
}
});
test('filter drawer opens when filter button is tapped', async ({ page }) => {
// Navigate to dashboard list
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
// Click first dashboard
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
if (cardCount > 0) {
await cards.first().click();
// Wait for dashboard
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Give filters time to load
await page.waitForTimeout(2000);
// Check for filter button
const filterButton = page
.locator('[data-test="filter-icon"]')
.or(page.locator('[aria-label="Filters"]'));
if ((await filterButton.count()) > 0) {
await filterButton.first().click();
// Filter drawer should open
const drawer = page
.locator('.ant-drawer-open')
.or(page.locator('[data-test="filter-bar"]'));
await expect(drawer.first()).toBeVisible({
timeout: TIMEOUT.FORM_LOAD,
});
}
}
});
});

View File

@@ -0,0 +1,221 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { test, expect, devices } from '@playwright/test';
// NOTE: These tests exercise the mobile consumption experience and require
// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target
// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}).
import { URL } from '../../utils/urls';
import { TIMEOUT } from '../../utils/constants';
/**
* Mobile navigation tests verify the MobileRouteGuard behavior
* and mobile-specific navigation patterns.
*
* These tests run with a mobile viewport to trigger mobile-specific behavior.
*/
// Use iPhone 12 viewport for mobile tests
const mobileViewport = devices['iPhone 12'];
test.describe('Mobile Navigation', () => {
test.use({
viewport: mobileViewport.viewport,
userAgent: mobileViewport.userAgent,
});
test.beforeEach(async ({ page }) => {
// Clear any previous bypass flags
await page.goto('/');
await page.evaluate(() => {
try {
sessionStorage.removeItem('mobile-bypass');
} catch {
// Ignore storage errors
}
});
});
test('mobile viewport redirects from chart list to MobileUnsupported page', async ({
page,
}) => {
// Navigate to chart list (not mobile-supported)
await page.goto(URL.CHART_LIST);
// Should show the MobileUnsupported page
await expect(
page.getByText("This view isn't available on mobile"),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// Primary action buttons should be visible
await expect(
page.getByRole('button', { name: 'View Dashboards' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Go to Welcome Page' }),
).toBeVisible();
});
test('mobile viewport allows access to dashboard list', async ({ page }) => {
// Navigate to dashboard list (mobile-supported)
await page.goto(URL.DASHBOARD_LIST);
// Should NOT show MobileUnsupported page
await expect(
page.getByText("This view isn't available on mobile"),
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
// Should show dashboard list content (look for dashboard list elements)
await expect(
page
.locator('[data-test="listview-table"]')
.or(page.locator('[data-test="styled-card"]'))
.first(),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
});
test('mobile viewport allows access to welcome page', async ({ page }) => {
// Navigate to welcome page (mobile-supported)
await page.goto(URL.WELCOME);
// Should NOT show MobileUnsupported page
await expect(
page.getByText("This view isn't available on mobile"),
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
// Should show welcome page content
await expect(
page.getByText('Recents').or(page.getByText('Dashboards')).first(),
).toBeVisible({
timeout: TIMEOUT.PAGE_LOAD,
});
});
test('View Dashboards button navigates to dashboard list', async ({
page,
}) => {
// Navigate to unsupported route
await page.goto(URL.CHART_LIST);
// Wait for MobileUnsupported page
await expect(
page.getByText("This view isn't available on mobile"),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// Click View Dashboards button
await page.getByRole('button', { name: 'View Dashboards' }).click();
// Should navigate to dashboard list
await page.waitForURL(url => url.pathname.includes('dashboard/list'), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Dashboard list should be accessible
await expect(
page
.locator('[data-test="listview-table"]')
.or(page.locator('[data-test="styled-card"]'))
.first(),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
});
test('Go to Welcome Page button navigates to welcome', async ({ page }) => {
// Navigate to unsupported route
await page.goto(URL.CHART_LIST);
// Wait for MobileUnsupported page
await expect(
page.getByText("This view isn't available on mobile"),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// Click Go to Welcome Page button
await page.getByRole('button', { name: 'Go to Welcome Page' }).click();
// Should navigate to welcome page
await page.waitForURL(url => url.pathname.includes('welcome'), {
timeout: TIMEOUT.PAGE_LOAD,
});
});
test('Continue anyway bypasses guard for the session', async ({ page }) => {
// Navigate to unsupported route
await page.goto(URL.CHART_LIST);
// Wait for MobileUnsupported page
await expect(
page.getByText("This view isn't available on mobile"),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// Click Continue anyway
await page.getByText('Continue anyway').click();
// Should navigate to the original destination
await page.waitForURL(url => url.pathname.includes('chart/list'), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Verify bypass is set in sessionStorage
const bypassValue = await page.evaluate(() =>
sessionStorage.getItem('mobile-bypass'),
);
expect(bypassValue).toBe('true');
// Navigate away and back - should still be bypassed
await page.goto(URL.WELCOME);
await page.goto(URL.CHART_LIST);
// Should NOT show MobileUnsupported page anymore
await expect(
page.getByText("This view isn't available on mobile"),
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
});
test('SQL Lab is not accessible on mobile', async ({ page }) => {
// Navigate to SQL Lab (not mobile-supported)
await page.goto(URL.SQLLAB);
// Should show the MobileUnsupported page
await expect(
page.getByText("This view isn't available on mobile"),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
});
});
test.describe('Desktop Navigation (control group)', () => {
// Use default desktop viewport
test('desktop viewport allows access to all routes', async ({ page }) => {
// Navigate to chart list
await page.goto(URL.CHART_LIST);
// Should NOT show MobileUnsupported page
await expect(
page.getByText("This view isn't available on mobile"),
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
// Should show chart list content
await expect(
page
.locator('[data-test="listview-table"]')
.or(page.locator('[data-test="styled-card"]'))
.first(),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
});
});

View File

@@ -0,0 +1,164 @@
/**
* 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,
},
});
/**
* Enables the MOBILE_CONSUMPTION_MODE feature flag on window.featureFlags.
* Mobile behavior requires BOTH a small viewport (mock Grid.useBreakpoint)
* AND this flag; call this in beforeAll/beforeEach of mobile test suites.
* Returns a cleanup function restoring the previous flags.
*/
export const enableMobileConsumptionFlag = () => {
const previous = window.featureFlags;
window.featureFlags = {
...window.featureFlags,
[FeatureFlag.MobileConsumptionMode]: true,
};
return () => {
window.featureFlags = previous;
};
};
/**
* Common mobile viewport dimensions for reference
*/
export const mobileViewports = {
iPhoneX: { width: 375, height: 812 },
iPhoneSE: { width: 375, height: 667 },
iPhone12Pro: { width: 390, height: 844 },
pixel5: { width: 393, height: 851 },
samsungGalaxyS20: { width: 360, height: 800 },
};
/**
* Common tablet viewport dimensions for reference
*/
export const tabletViewports = {
iPadMini: { width: 768, height: 1024 },
iPadAir: { width: 820, height: 1180 },
iPadPro11: { width: 834, height: 1194 },
surfacePro7: { width: 912, height: 1368 },
};
/**
* Helper to mock sessionStorage for tests
*/
export const mockSessionStorage = () => {
const store: Record<string, string> = {};
return {
getItem: jest.fn((key: string) => store[key] ?? null),
setItem: jest.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: jest.fn((key: string) => {
delete store[key];
}),
clear: jest.fn(() => {
Object.keys(store).forEach(key => delete store[key]);
}),
get length() {
return Object.keys(store).length;
},
key: jest.fn((index: number) => Object.keys(store)[index] ?? null),
};
};
/**
* Helper to create a failing sessionStorage mock (simulates privacy mode)
*/
export const mockSessionStorageFailure = () => ({
getItem: jest.fn(() => {
throw new Error('Storage access denied');
}),
setItem: jest.fn(() => {
throw new Error('Storage access denied');
}),
removeItem: jest.fn(() => {
throw new Error('Storage access denied');
}),
clear: jest.fn(() => {
throw new Error('Storage access denied');
}),
length: 0,
key: jest.fn(() => null),
});

View File

@@ -19,6 +19,7 @@
import { ReactNode, MouseEvent as ReactMouseEvent } from 'react';
import { TableInstance, Row, UseRowSelectRowProps } from 'react-table';
import { styled } from '@apache-superset/core/theme';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import cx from 'classnames';
interface CardCollectionProps {
@@ -42,6 +43,18 @@ const CardContainer = styled.div<{ showThumbnails?: boolean }>`
? `${theme.sizeUnit * 8 + 3}px ${theme.sizeUnit * 20}px`
: `${theme.sizeUnit * 8 + 1}px ${theme.sizeUnit * 20}px`
};
/* Full-width cards on mobile (consumption mode) */
${
isMobileConsumptionEnabled()
? `@media (max-width: ${theme.screenSMMax}px) {
grid-template-columns: 1fr;
grid-gap: ${theme.sizeUnit * 4}px;
padding-left: ${theme.sizeUnit * 4}px;
padding-right: ${theme.sizeUnit * 4}px;
}`
: ''
}
`}
`;

View File

@@ -386,3 +386,227 @@ describe('ListView', () => {
expect(screen.getByTestId('empty-state')).toHaveClass('card');
});
});
// Mobile support tests
test('respects forceViewMode prop and hides view toggle', () => {
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
const { cardSortSelectOptions, ...propsWithoutSort } =
mockedPropsComprehensive;
render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<ListView
{...propsWithoutSort}
renderCard={() => <div>Card</div>}
forceViewMode="card"
/>
</QueryParamProvider>
</MemoryRouter>,
{ store: mockStore() },
);
// View toggle should not be present when forceViewMode is set
expect(screen.queryByLabelText('card-view')).not.toBeInTheDocument();
expect(screen.queryByLabelText('list-view')).not.toBeInTheDocument();
});
test('shows card view when forceViewMode is card', () => {
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
const { cardSortSelectOptions, ...propsWithoutSort } =
mockedPropsComprehensive;
render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<ListView
{...propsWithoutSort}
renderCard={() => <div data-test="test-card">Card Content</div>}
forceViewMode="card"
/>
</QueryParamProvider>
</MemoryRouter>,
{ store: mockStore() },
);
// Should render cards, not table rows
expect(screen.getAllByTestId('test-card')).toHaveLength(2);
});
test('renders mobile filter drawer when mobileFiltersOpen is true', () => {
const setMobileFiltersOpen = jest.fn();
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
const { cardSortSelectOptions, ...propsWithoutSort } =
mockedPropsComprehensive;
render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<ListView
{...propsWithoutSort}
mobileFiltersOpen
setMobileFiltersOpen={setMobileFiltersOpen}
mobileFiltersDrawerTitle="Search Dashboards"
/>
</QueryParamProvider>
</MemoryRouter>,
{ store: mockStore() },
);
// Drawer should be visible with custom title
expect(screen.getByText('Search Dashboards')).toBeInTheDocument();
});
test('calls setMobileFiltersOpen(false) when drawer is closed', async () => {
const setMobileFiltersOpen = jest.fn();
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
const { cardSortSelectOptions, ...propsWithoutSort } =
mockedPropsComprehensive;
render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<ListView
{...propsWithoutSort}
mobileFiltersOpen
setMobileFiltersOpen={setMobileFiltersOpen}
mobileFiltersDrawerTitle="Search"
/>
</QueryParamProvider>
</MemoryRouter>,
{ store: mockStore() },
);
// Click the close button on the drawer
const closeButton = screen.getByLabelText('Close');
await userEvent.click(closeButton);
expect(setMobileFiltersOpen).toHaveBeenCalledWith(false);
});
test('mobile drawer contains FilterControls', () => {
const setMobileFiltersOpen = jest.fn();
const { cardSortSelectOptions, ...propsWithoutSort } =
mockedPropsComprehensive;
render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<ListView
{...propsWithoutSort}
mobileFiltersOpen
setMobileFiltersOpen={setMobileFiltersOpen}
/>
</QueryParamProvider>
</MemoryRouter>,
{ store: mockStore() },
);
// The drawer should contain the filter controls; select filters render
// as popover triggers (aria-haspopup="listbox") in the drawer
const drawer = screen.getByRole('dialog');
const filterTriggers = drawer.querySelectorAll('[aria-haspopup="listbox"]');
expect(filterTriggers.length).toBeGreaterThan(0);
});
test('mobile drawer contains CardSortSelect when in card view with sort options', () => {
const setMobileFiltersOpen = jest.fn();
render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<ListView
{...mockedPropsComprehensive}
renderCard={() => <div>Card</div>}
forceViewMode="card"
mobileFiltersOpen
setMobileFiltersOpen={setMobileFiltersOpen}
initialSort={[{ id: 'something' }]}
/>
</QueryParamProvider>
</MemoryRouter>,
{ store: mockStore() },
);
// Sort select should be present (may be multiple - one in drawer, one in header)
const sortSelects = screen.getAllByTestId('card-sort-select');
expect(sortSelects.length).toBeGreaterThan(0);
});
test('uses default drawer title when mobileFiltersDrawerTitle not provided', () => {
const setMobileFiltersOpen = jest.fn();
const { cardSortSelectOptions, ...propsWithoutSort } =
mockedPropsComprehensive;
render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<ListView
{...propsWithoutSort}
mobileFiltersOpen
setMobileFiltersOpen={setMobileFiltersOpen}
/>
</QueryParamProvider>
</MemoryRouter>,
{ store: mockStore() },
);
// Default title should be 'Search'
expect(screen.getByText('Search')).toBeInTheDocument();
});
test('does not render drawer when mobileFiltersOpen is false', () => {
const setMobileFiltersOpen = jest.fn();
const { cardSortSelectOptions, ...propsWithoutSort } =
mockedPropsComprehensive;
render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<ListView
{...propsWithoutSort}
mobileFiltersOpen={false}
setMobileFiltersOpen={setMobileFiltersOpen}
mobileFiltersDrawerTitle="Search"
/>
</QueryParamProvider>
</MemoryRouter>,
{ store: mockStore() },
);
// Drawer should not be visible (title not in visible content)
// Note: Ant Design drawer might still be in DOM but hidden
const drawer = document.querySelector('.ant-drawer-open');
expect(drawer).toBeNull();
});
test('does not render mobile drawer without setMobileFiltersOpen prop', () => {
const { cardSortSelectOptions, ...propsWithoutSort } =
mockedPropsComprehensive;
render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<ListView {...propsWithoutSort} />
</QueryParamProvider>
</MemoryRouter>,
{ store: mockStore() },
);
// No drawer elements should exist
const drawer = document.querySelector('.ant-drawer');
expect(drawer).toBeNull();
});
test('forceViewMode table shows table view', () => {
const { cardSortSelectOptions, ...propsWithoutSort } =
mockedPropsComprehensive;
render(
<MemoryRouter>
<QueryParamProvider adapter={ReactRouter5Adapter}>
<ListView
{...propsWithoutSort}
renderCard={() => <div data-test="card">Card</div>}
forceViewMode="table"
/>
</QueryParamProvider>
</MemoryRouter>,
{ store: mockStore() },
);
// Should show table, not cards
expect(screen.queryByTestId('card')).not.toBeInTheDocument();
// Table should be present
expect(screen.getByRole('table')).toBeInTheDocument();
});

View File

@@ -34,6 +34,7 @@ import BulkTagModal from 'src/features/tags/BulkTagModal';
import {
Button,
Tooltip,
Drawer,
Icons,
EmptyState,
Loading,
@@ -239,6 +240,30 @@ const EmptyWrapper = styled.div`
`}
`;
const MobileFilterDrawerContent = styled.div`
${({ theme }) => `
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 4}px;
padding: ${theme.sizeUnit * 2}px;
/* Make filter inputs stack vertically and full-width */
> * {
width: 100%;
}
/* Override inline filter styling for vertical layout */
.filter-container {
width: 100%;
}
input[type="text"],
.ant-select {
width: 100% !important;
}
`}
`;
const ViewModeToggle = ({
mode,
setMode,
@@ -305,6 +330,7 @@ export interface ListViewProps<T extends object = any> {
renderCard?: (row: T & { loading: boolean }) => ReactNode;
cardSortSelectOptions?: Array<CardSortSelectOption>;
defaultViewMode?: ViewModeType;
forceViewMode?: ViewModeType;
highlightRowId?: number;
showThumbnails?: boolean;
emptyState?: EmptyStateProps;
@@ -320,6 +346,12 @@ export interface ListViewProps<T extends object = any> {
expandable?: Record<string, unknown>;
/** Content rendered between the filter bar and the table/card body. */
headerContent?: ReactNode;
/** Whether mobile filters drawer is open (controlled externally) */
mobileFiltersOpen?: boolean;
/** Callback to set mobile filters drawer open state */
setMobileFiltersOpen?: (open: boolean) => void;
/** Title for the mobile filters drawer */
mobileFiltersDrawerTitle?: string;
}
export function ListView<T extends object = any>({
@@ -341,6 +373,7 @@ export function ListView<T extends object = any>({
showThumbnails,
cardSortSelectOptions,
defaultViewMode = 'card',
forceViewMode,
highlightRowId,
emptyState,
columnsForWrapText,
@@ -351,6 +384,9 @@ export function ListView<T extends object = any>({
headerContent,
addSuccessToast,
addDangerToast,
mobileFiltersOpen = false,
setMobileFiltersOpen,
mobileFiltersDrawerTitle,
}: ListViewProps<T>) {
const {
getTableProps,
@@ -377,6 +413,7 @@ export function ListView<T extends object = any>({
initialFilters: filters,
renderCard: Boolean(renderCard),
defaultViewMode,
forceViewMode,
});
const allowBulkTagActions = bulkTagResourceName && enableBulkTag;
const filterable = Boolean(filters.length);
@@ -453,11 +490,15 @@ export function ListView<T extends object = any>({
)}
<div data-test={className} className={`superset-list-view ${className} `}>
<div className="header">
{cardViewEnabled && (
{cardViewEnabled && !forceViewMode && (
<ViewModeToggle mode={viewMode} setMode={setViewMode} />
)}
<div className="controls" data-test="filters-select">
{filterable && (
{/* When a mobile drawer callback is provided, filters and sort
render inside the drawer instead of inline. Only one
FilterControls instance is ever mounted, so filtersRef and
filterControlsRef always point at the visible instance. */}
{filterable && !setMobileFiltersOpen && (
<FilterControls
ref={filterControlsRef}
filters={filters}
@@ -465,13 +506,15 @@ export function ListView<T extends object = any>({
updateFilterValue={applyFilterValue}
/>
)}
{viewMode === 'card' && cardSortSelectOptions && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
{viewMode === 'card' &&
cardSortSelectOptions &&
!setMobileFiltersOpen && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
{filterable && (
<Tooltip
title={!hasActiveFilters ? t('No filters applied') : undefined}
@@ -659,6 +702,33 @@ export function ListView<T extends object = any>({
)}
</div>
</div>
{/* Mobile filter drawer */}
{filterable && setMobileFiltersOpen && (
<Drawer
title={mobileFiltersDrawerTitle || t('Search')}
placement="left"
onClose={() => setMobileFiltersOpen(false)}
open={mobileFiltersOpen}
width={300}
>
<MobileFilterDrawerContent>
<FilterControls
ref={filterControlsRef}
filters={filters}
internalFilters={internalFilters}
updateFilterValue={applyFilterValue}
/>
{viewMode === 'card' && cardSortSelectOptions && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
</MobileFilterDrawerContent>
</Drawer>
)}
</ListViewStyles>
);
}

View File

@@ -195,6 +195,7 @@ interface UseListViewConfig {
initialFilters?: Filter[];
renderCard?: boolean;
defaultViewMode?: ViewModeType;
forceViewMode?: ViewModeType;
}
export function useListViewState({
@@ -207,6 +208,7 @@ export function useListViewState({
initialSort = [],
renderCard = false,
defaultViewMode = 'card',
forceViewMode,
}: UseListViewConfig) {
const [query, setQuery] = useQueryParams({
filters: RisonParam,
@@ -234,10 +236,19 @@ export function useListViewState({
};
const [viewMode, setViewMode] = useState<ViewModeType>(
(query.viewMode as ViewModeType) ||
// forceViewMode overrides everything (used for mobile)
forceViewMode ||
(query.viewMode as ViewModeType) ||
(renderCard ? defaultViewMode : 'table'),
);
// Update viewMode when forceViewMode changes (e.g., screen resize)
useEffect(() => {
if (forceViewMode) {
setViewMode(forceViewMode);
}
}, [forceViewMode]);
const columnsWithFilter = useMemo(
// add exact filter type so filters with falsy values are not filtered out
() => columns.map(f => ({ ...f, filter: 'exact' })),

View File

@@ -0,0 +1,73 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from 'spec/helpers/testing-library';
import { useIsMobile } from 'src/hooks/useIsMobile';
import MobileRouteGuard from '.';
import { MOBILE_BYPASS_STORAGE_KEY } from '../../pages/MobileUnsupported';
jest.mock('src/hooks/useIsMobile', () => ({
useIsMobile: jest.fn(),
isMobileConsumptionEnabled: jest.fn().mockReturnValue(true),
}));
const mockedUseIsMobile = useIsMobile as jest.MockedFunction<
typeof useIsMobile
>;
const renderGuard = (mobileSupported?: boolean) =>
render(
<MemoryRouter initialEntries={['/some/route/']}>
<MobileRouteGuard mobileSupported={mobileSupported}>
<div data-test="guarded-content">Content</div>
</MobileRouteGuard>
</MemoryRouter>,
);
beforeEach(() => {
sessionStorage.clear();
mockedUseIsMobile.mockReturnValue(false);
});
test('renders children on desktop regardless of mobileSupported', () => {
renderGuard(undefined);
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
});
test('renders children on mobile when the route is mobileSupported', () => {
mockedUseIsMobile.mockReturnValue(true);
renderGuard(true);
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
});
test('shows the unsupported screen on mobile for unsupported routes', () => {
mockedUseIsMobile.mockReturnValue(true);
renderGuard(undefined);
expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument();
expect(
screen.getByText("This view isn't available on mobile"),
).toBeInTheDocument();
});
test('renders children on mobile when the bypass flag is set', () => {
mockedUseIsMobile.mockReturnValue(true);
sessionStorage.setItem(MOBILE_BYPASS_STORAGE_KEY, 'true');
renderGuard(undefined);
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
});

View File

@@ -0,0 +1,78 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useIsMobile } from 'src/hooks/useIsMobile';
import MobileUnsupported, {
MOBILE_BYPASS_STORAGE_KEY,
} from 'src/pages/MobileUnsupported';
interface MobileRouteGuardProps {
children: ReactNode;
/**
* Whether the wrapped route is part of the mobile consumption
* experience. Set via the `mobileSupported` flag on the route
* definition in `src/views/routes.tsx`.
*/
mobileSupported?: boolean;
}
/**
* Wraps route content and shows the MobileUnsupported page when a
* non-mobile-friendly route is accessed on a small screen with
* MOBILE_CONSUMPTION_MODE enabled.
*
* Users can bypass this by clicking "Continue anyway", which sets a
* sessionStorage flag for the rest of the browser session.
*/
function MobileRouteGuard({
children,
mobileSupported,
}: MobileRouteGuardProps) {
const isMobile = useIsMobile();
const location = useLocation();
const [bypassEnabled, setBypassEnabled] = useState(() => {
try {
return sessionStorage.getItem(MOBILE_BYPASS_STORAGE_KEY) === 'true';
} catch {
return false;
}
});
// Check for bypass flag when location changes
useEffect(() => {
try {
const bypass =
sessionStorage.getItem(MOBILE_BYPASS_STORAGE_KEY) === 'true';
setBypassEnabled(bypass);
} catch {
// Storage access denied, keep current state
}
}, [location.pathname]);
if (!isMobile || bypassEnabled || mobileSupported) {
return <>{children}</>;
}
return (
<MobileUnsupported originalPath={location.pathname + location.search} />
);
}
export default MobileRouteGuard;

View File

@@ -50,6 +50,15 @@ fetchMock.put('glob:*/api/v1/dashboard/*', {});
// Add mock for logging endpoint
fetchMock.post('glob:*/log/?*', {});
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Grid: {
...jest.requireActual('antd').Grid,
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
},
}));
jest.mock('src/dashboard/actions/dashboardState', () => ({
...jest.requireActual('src/dashboard/actions/dashboardState'),
fetchFaveStar: jest.fn(),
@@ -422,6 +431,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { getByTestId } = setup();
@@ -448,6 +458,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: false,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { getByTestId } = setup();
@@ -474,6 +485,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: false,
hasFilters: false,
});
const { getByTestId } = setup();
@@ -533,6 +545,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: false,
hasFilters: false,
});
const { queryByTestId } = setup();
@@ -546,6 +559,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { queryByTestId } = setup();
@@ -559,6 +573,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { queryByTestId } = setup({
dashboardState: { ...mockState.dashboardState, editMode: true },
@@ -719,3 +734,85 @@ test('should maintain layout when switching between tabs', async () => {
expect(gridContainer).toBeInTheDocument();
expect(tabPanels.length).toBeGreaterThan(0);
});
// Mobile support tests
// Note: The main mobile tests require mocking useBreakpoint to return mobile breakpoints
// which is done at the module level. These tests verify mobile-related component behavior.
test('should not render filter bar panel on desktop when nativeFiltersEnabled is false', () => {
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
100,
jest.fn(),
]);
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
jest.spyOn(useNativeFiltersModule, 'useNativeFilters').mockReturnValue({
showDashboard: true,
missingInitialFilters: [],
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: false,
hasFilters: false,
});
const { queryByTestId } = render(<DashboardBuilder />, {
useRedux: true,
store: storeWithState({
...mockState,
dashboardLayout: undoableDashboardLayout,
}),
useDnd: true,
useTheme: true,
useRouter: true,
});
// Filter panel should not be present when native filters are disabled
expect(queryByTestId('dashboard-filters-panel')).not.toBeInTheDocument();
});
test('should render dashboard content wrapper', () => {
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
100,
jest.fn(),
]);
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
const { getByTestId } = render(<DashboardBuilder />, {
useRedux: true,
store: storeWithState({
...mockState,
dashboardLayout: undoableDashboardLayout,
}),
useDnd: true,
useTheme: true,
useRouter: true,
});
// Dashboard content wrapper should always be present
expect(getByTestId('dashboard-content-wrapper')).toBeInTheDocument();
});
test('should render header container', () => {
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
100,
jest.fn(),
]);
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
const { queryByTestId } = render(<DashboardBuilder />, {
useRedux: true,
store: storeWithState({
...mockState,
dashboardLayout: undoableDashboardLayout,
}),
useDnd: true,
useTheme: true,
useRouter: true,
});
// Header container should be present
expect(queryByTestId('dashboard-header-container')).toBeInTheDocument();
});

View File

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

View File

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

View File

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

View File

@@ -186,6 +186,15 @@ const recordError = jest.fn();
const setPaused = jest.fn();
const setPausedByTab = jest.fn();
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Grid: {
...jest.requireActual('antd').Grid,
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
},
}));
jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
useUnsavedChangesPrompt: jest.fn(),
}));

View File

@@ -23,7 +23,12 @@ import {
FeatureFlag,
getExtensionsRegistry,
} from '@superset-ui/core';
import { styled, css, SupersetTheme } from '@apache-superset/core/theme';
import {
styled,
css,
SupersetTheme,
useTheme,
} from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { Global } from '@emotion/react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
@@ -37,6 +42,7 @@ import {
UnsavedChangesModal,
} from '@superset-ui/core/components';
import { findPermission } from 'src/utils/findPermission';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { safeStringify } from 'src/utils/safeStringify';
import Subject from 'src/types/Subject';
import { DashboardLayout, RootState } from 'src/dashboard/types';
@@ -219,8 +225,14 @@ const discardChanges = () => {
window.location.assign(url);
};
const Header = (): JSX.Element => {
interface HeaderComponentProps {
onOpenMobileFilters?: () => void;
}
const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
const dispatch = useDispatch();
const theme = useTheme();
const isMobile = useIsMobile();
const [didNotifyMaxUndoHistoryToast, setDidNotifyMaxUndoHistoryToast] =
useState(false);
const [emphasizeUndo, setEmphasizeUndo] = useState(false);
@@ -640,7 +652,7 @@ const Header = (): JSX.Element => {
onTogglePause={handlePauseToggle}
/>
),
!editMode && (
!editMode && !isMobile && (
<PublishedStatus
key="published-status"
dashboardId={dashboardInfo.id}
@@ -650,12 +662,13 @@ const Header = (): JSX.Element => {
userCanSave={userCanSaveAs}
/>
),
!editMode && !isEmbedded && metadataBar,
!editMode && !isEmbedded && !isMobile && metadataBar,
],
[
boundActionCreators.savePublished,
dashboardInfo.id,
editMode,
isMobile,
metadataBar,
isEmbedded,
isPublished,
@@ -750,7 +763,7 @@ const Header = (): JSX.Element => {
) : (
<div css={actionButtonsStyle}>
{NavExtension && <NavExtension />}
{userCanEdit && !isEmbedded && (
{userCanEdit && !isEmbedded && !isMobile && (
<Button
buttonStyle="secondary"
onClick={handleEnterEditMode}
@@ -778,6 +791,7 @@ const Header = (): JSX.Element => {
handleEnterEditMode,
hasUnsavedChanges,
isEmbedded,
isMobile,
overwriteDashboard,
redoLength,
undoLength,
@@ -814,6 +828,10 @@ const Header = (): JSX.Element => {
userCanCurate,
userCanExport,
isLoading,
isMobile,
isStarred,
isPublished,
saveFaveStar: boundActionCreators.saveFaveStar,
showReportModal,
showPropertiesModal,
showRefreshModal,
@@ -833,6 +851,21 @@ const Header = (): JSX.Element => {
editableTitleProps={editableTitleProps}
certificatiedBadgeProps={certifiedBadgeProps}
faveStarProps={faveStarProps}
leftPanelItems={
onOpenMobileFilters && (
<Button
buttonStyle="link"
aria-label={t('Open filters')}
onClick={onOpenMobileFilters}
data-test="mobile-filters-trigger"
>
<Icons.FilterOutlined
iconColor={theme.colorPrimary}
iconSize="l"
/>
</Button>
)
}
titlePanelAdditionalItems={titlePanelAdditionalItems}
rightPanelAdditionalItems={rightPanelAdditionalItems}
menuDropdownProps={{
@@ -840,7 +873,7 @@ const Header = (): JSX.Element => {
onOpenChange: setIsDropdownVisible,
}}
additionalActionsMenu={menu}
showFaveStar={Boolean(user?.userId && dashboardInfo?.id)}
showFaveStar={!!(user?.userId && dashboardInfo?.id && !isMobile)}
showTitlePanelItems
/>
{showingPropertiesModal && (

View File

@@ -43,6 +43,10 @@ export interface HeaderDropdownProps {
forceRefreshAllCharts: () => unknown;
hasUnsavedChanges: boolean;
isLoading: boolean;
isMobile?: boolean;
isStarred?: boolean;
isPublished?: boolean;
saveFaveStar?: (id: number, isStarred: boolean) => void;
layout: Layout;
onSave: (...args: unknown[]) => unknown;
refreshFrequency: number;

View File

@@ -37,6 +37,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
import { usePermissions } from 'src/hooks/usePermissions';
import getUserName from 'src/utils/getUserName';
export const useHeaderActionsMenu = ({
customCss,
@@ -56,6 +57,10 @@ export const useHeaderActionsMenu = ({
userCanCurate,
userCanExport,
isLoading,
isMobile,
isStarred,
isPublished,
saveFaveStar,
lastModifiedTime,
addSuccessToast,
addDangerToast,
@@ -117,6 +122,11 @@ export const useHeaderActionsMenu = ({
case MenuKeys.ManageEmbedded:
manageEmbedded();
break;
case 'toggle-favorite':
if (saveFaveStar && isStarred !== undefined) {
saveFaveStar(dashboardId, isStarred);
}
break;
default:
break;
}
@@ -128,6 +138,9 @@ export const useHeaderActionsMenu = ({
showPropertiesModal,
showRefreshModal,
manageEmbedded,
saveFaveStar,
dashboardId,
isStarred,
history,
location,
],
@@ -205,6 +218,52 @@ export const useHeaderActionsMenu = ({
const menuItems: MenuItem[] = [];
// Mobile-only: show dashboard info items in menu
if (isMobile && !editMode) {
// Favorite toggle
if (saveFaveStar) {
menuItems.push({
key: 'toggle-favorite',
label: isStarred ? t('Remove from favorites') : t('Add to favorites'),
});
}
// Published status
menuItems.push({
key: 'status-info',
label: isPublished ? t('Status: Published') : t('Status: Draft'),
disabled: true,
});
// Editor info
const editorNames = dashboardInfo?.editors?.length
? dashboardInfo.editors
.map((editor: { label?: string }) => editor.label)
.filter(Boolean)
.join(', ')
: t('None');
menuItems.push({
key: 'owner-info',
label: t('Owner: %(names)s', { names: editorNames }),
disabled: true,
});
// Last modified
const modifiedBy =
getUserName(dashboardInfo?.changed_by) || t('Not available');
const modifiedDate = dashboardInfo?.changed_on_delta_humanized || '';
menuItems.push({
key: 'modified-info',
label: t('Modified %(date)s by %(user)s', {
date: modifiedDate,
user: modifiedBy,
}),
disabled: true,
});
menuItems.push({ type: 'divider' });
}
// Refresh dashboard
if (!editMode) {
menuItems.push({
@@ -224,8 +283,8 @@ export const useHeaderActionsMenu = ({
});
}
// Toggle fullscreen
if (!editMode && !isEmbedded) {
// Toggle fullscreen (hide on mobile)
if (!editMode && !isEmbedded && !isMobile) {
menuItems.push({
key: MenuKeys.ToggleFullscreen,
label: getUrlParam(URL_PARAMS.standalone)
@@ -293,15 +352,15 @@ export const useHeaderActionsMenu = ({
// Only add divider if there are items after it
const hasItemsAfterDivider =
(!editMode && reportMenuItem) ||
(!editMode && reportMenuItem && !isMobile) ||
(editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes));
if (hasItemsAfterDivider) {
menuItems.push({ type: 'divider' });
}
// Report dropdown
if (!editMode && reportMenuItem) {
// Report dropdown (hide on mobile)
if (!editMode && reportMenuItem && !isMobile) {
menuItems.push(reportMenuItem);
}
@@ -339,11 +398,15 @@ export const useHeaderActionsMenu = ({
expandedSlices,
handleMenuClick,
isLoading,
isMobile,
isPublished,
isStarred,
lastModifiedTime,
layout,
onSave,
refreshFrequency,
reportMenuItem,
saveFaveStar,
shareMenuItems,
shouldPersistRefreshFrequency,
userCanCurate,

View File

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

View File

@@ -36,10 +36,12 @@ 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;
@@ -96,6 +98,7 @@ const ChartHolder = ({
isInView,
}: ChartHolderProps) => {
const theme = useTheme();
const isMobile = useIsMobile();
const fullSizeStyle = css`
&& {
position: fixed !important;
@@ -167,6 +170,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,6 +193,8 @@ const ChartHolder = ({
}, [
component,
getComponentById,
isMobile,
editMode,
parentComponent.meta.width,
parentComponent.parents,
parentComponent.type,

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,18 @@ 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;
}
}
`}
`}
`;

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,20 @@ 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};
}
}
`}
`;
export interface TabItem {

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,11 +59,15 @@ jest.mock('@apache-superset/core/theme', () => ({
useTheme: jest.fn(),
}));
// Mock useBreakpoint to return desktop breakpoints (prevents mobile menu rendering)
// Note: We mock 'antd' directly rather than '@superset-ui/core/components' because
// mocking the latter causes circular dependency issues with ActionButton during
// jest.requireActual evaluation. Since Grid is re-exported from antd, this works.
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Grid: {
...jest.requireActual('antd').Grid,
useBreakpoint: () => ({ md: true }),
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
},
}));

View File

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

View File

@@ -50,6 +50,15 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
// Mock useBreakpoint to return desktop breakpoints (prevents mobile menu rendering)
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Grid: {
...jest.requireActual('antd').Grid,
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
},
}));
jest.mock('src/features/databases/DatabaseModal', () => {
const DatabaseModal = () => <span />;
DatabaseModal.displayName = 'DatabaseModal';

View File

@@ -51,9 +51,12 @@ import {
Icons,
Typography,
TelemetryPixel,
Drawer,
Button,
} from '@superset-ui/core/components';
import type { ItemType, MenuItem } from '@superset-ui/core/components/Menu';
import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { findPermission } from 'src/utils/findPermission';
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
@@ -104,12 +107,10 @@ const StyledMenuItem = styled.div<{ disabled?: boolean }>`
color: ${!disabled && theme.colorPrimary};
cursor: ${!disabled ? 'pointer' : 'not-allowed'};
}
${
disabled &&
css`
color: ${theme.colorTextDisabled};
`
}
${disabled &&
css`
color: ${theme.colorTextDisabled};
`}
`}
`;
@@ -119,6 +120,7 @@ const RightMenu = ({
navbarRight,
isFrontendRoute,
environmentTag,
menu,
setQuery,
}: RightMenuProps & {
setQuery: ({
@@ -130,6 +132,8 @@ const RightMenu = ({
}) => void;
}) => {
const theme = useTheme();
const isMobile = useIsMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const user = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
@@ -646,6 +650,64 @@ const RightMenu = ({
handleLogout,
]);
// Build mobile menu items - consumption only (no create/admin actions)
const mobileMenuItems = useMemo(() => {
const items: MenuItem[] = [];
// Add Dashboards link at top (from main menu)
// Match on the FAB-internal `name`, which is stable across locales
// (`label` is translated and would break in non-English deployments)
const dashboardsMenu = menu?.find(item => item.name === 'Dashboards');
if (dashboardsMenu) {
const dashboardUrl = dashboardsMenu.url || '/dashboard/list/';
items.push({
key: 'dashboards',
label: isFrontendRoute(dashboardUrl) ? (
<Link to={dashboardUrl}>{t('Dashboards')}</Link>
) : (
<Typography.Link href={dashboardUrl}>
{t('Dashboards')}
</Typography.Link>
),
icon: <Icons.DashboardOutlined />,
});
}
// Add theme menu (flatten children directly)
menuItems.forEach(item => {
if (!item || !('key' in item)) return;
// Only include theme-sub-menu and language picker
if (item.key === 'theme-sub-menu' || item.key === 'language-picker') {
items.push({ type: 'divider', key: `divider-before-${item.key}` });
if ('children' in item && item.children) {
// Theme menu already has a nested group, so just add its children directly
item.children.forEach(child => {
items.push(child);
});
} else {
items.push(item);
}
}
// Extract user-related items from settings
if (item.key === 'settings' && 'children' in item && item.children) {
item.children.forEach(child => {
if (!child || !('key' in child)) return;
// Only include user-section and about-section
if (child.key === 'user-section' || child.key === 'about-section') {
items.push({ type: 'divider', key: `divider-before-${child.key}` });
items.push(child);
}
});
}
});
return items;
}, [menu, menuItems, isFrontendRoute]);
return (
<StyledDiv align={align}>
{canDatabase && (
@@ -706,48 +768,87 @@ const RightMenu = ({
</Tag>
);
})()}
<Menu
css={css`
display: flex;
flex-direction: row;
align-items: center;
height: 100%;
border-bottom: none !important;
/* Remove the underline from menu items */
.ant-menu-item:after,
.ant-menu-submenu:after {
content: none !important;
}
.submenu-with-caret {
{/* Mobile: hamburger menu with drawer */}
{isMobile && (
<>
<Button
buttonStyle="link"
onClick={() => setMobileMenuOpen(true)}
aria-label={t('Menu')}
>
<Icons.MenuOutlined iconSize="l" />
</Button>
<Drawer
title={null}
placement="right"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
width={280}
styles={{
header: { display: 'none' },
body: { padding: 0 },
}}
>
<Menu
mode="inline"
selectable={false}
onClick={info => {
handleMenuSelection(info);
setMobileMenuOpen(false);
}}
items={mobileMenuItems}
css={css`
border-inline-end: none !important;
`}
/>
</Drawer>
</>
)}
{/* Desktop: horizontal menu */}
{!isMobile && (
<Menu
css={css`
display: flex;
flex-direction: row;
align-items: center;
height: 100%;
padding: 0;
.ant-menu-submenu-title {
align-items: center;
display: flex;
gap: ${theme.sizeUnit * 2}px;
flex-direction: row-reverse;
border-bottom: none !important;
/* Remove the underline from menu items */
.ant-menu-item:after,
.ant-menu-submenu:after {
content: none !important;
}
.submenu-with-caret {
height: 100%;
}
&.ant-menu-submenu::after {
inset-inline: ${theme.sizeUnit}px;
}
&.ant-menu-submenu:hover,
&.ant-menu-submenu-active {
.ant-menu-title-content {
color: ${theme.colorPrimary};
padding: 0;
.ant-menu-submenu-title {
align-items: center;
display: flex;
gap: ${theme.sizeUnit * 2}px;
flex-direction: row-reverse;
height: 100%;
}
&.ant-menu-submenu::after {
inset-inline: ${theme.sizeUnit}px;
}
&.ant-menu-submenu:hover,
&.ant-menu-submenu-active {
.ant-menu-title-content {
color: ${theme.colorPrimary};
}
}
}
}
`}
selectable={false}
mode="horizontal"
onClick={handleMenuSelection}
onOpenChange={onMenuOpen}
disabledOverflow
items={menuItems}
/>
`}
selectable={false}
mode="horizontal"
onClick={handleMenuSelection}
onOpenChange={onMenuOpen}
disabledOverflow
items={menuItems}
/>
)}
{navbarRight.documentation_url && (
<>
<StyledAnchor

View File

@@ -127,3 +127,35 @@ test('should render the buttons', async () => {
userEvent.click(testButton);
expect(mockFunc).toHaveBeenCalled();
});
// Mobile support tests
test('should render leftIcon when provided', async () => {
setup({
leftIcon: (
<button type="button" data-test="left-icon-button">
Search
</button>
),
});
expect(await screen.findByTestId('left-icon-button')).toBeInTheDocument();
});
test('should render rightIcon when provided', async () => {
setup({
rightIcon: (
<button type="button" data-test="right-icon-button">
Menu
</button>
),
});
expect(await screen.findByTestId('right-icon-button')).toBeInTheDocument();
});
test('should render both leftIcon and rightIcon together', async () => {
setup({
leftIcon: <span data-test="mobile-left">Left</span>,
rightIcon: <span data-test="mobile-right">Right</span>,
});
expect(await screen.findByTestId('mobile-left')).toBeInTheDocument();
expect(await screen.findByTestId('mobile-right')).toBeInTheDocument();
});

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
/**
* 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 { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { Grid } from '@superset-ui/core/components';
/**
* 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 the viewport is below antd's `md` breakpoint AND the
* MOBILE_CONSUMPTION_MODE feature flag is enabled. All mobile-specific
* behavior (route guarding, consumption-only chrome, drawer navigation)
* should key off this hook so the flag remains a single kill switch.
*
* Defaults to desktop on the first render, before antd's
* ResponsiveObserver has fired, so the initial paint never takes the
* mobile branch by accident.
*/
export function useIsMobile(): boolean {
const { md = true } = Grid.useBreakpoint();
return !md && isMobileConsumptionEnabled();
}

View File

@@ -45,6 +45,15 @@ jest.mock('src/utils/export', () => ({
default: jest.fn(),
}));
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Grid: {
...jest.requireActual('antd').Grid,
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
},
}));
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
typeof isFeatureEnabled
>;

View File

@@ -23,7 +23,7 @@ import {
SupersetClient,
handleKeyboardActivation,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { useSelector } from 'react-redux';
import { useState, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
@@ -39,7 +39,9 @@ import Subject from 'src/types/Subject';
import { SUBJECT_OPTION_FILTER_PROPS } from 'src/features/subjects/SubjectSelectLabel';
import { SubjectPile } from 'src/features/subjects/SubjectPile';
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
import { useIsMobile } from 'src/hooks/useIsMobile';
import {
Button,
CertifiedBadge,
ConfirmStatusChange,
DeleteModal,
@@ -175,6 +177,9 @@ const DASHBOARD_COLUMNS_TO_FETCH = [
function DashboardList(props: DashboardListProps) {
const { addDangerToast, addSuccessToast, user } = props;
const isNotMobile = !useIsMobile();
const theme = useTheme();
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
@@ -824,7 +829,25 @@ function DashboardList(props: DashboardListProps) {
}
return (
<>
<SubMenu name={t('Dashboards')} buttons={subMenuButtons} />
<SubMenu
name={t('Dashboards')}
buttons={subMenuButtons}
leftIcon={
!isNotMobile ? (
<Button
buttonStyle="link"
onClick={() => setMobileFiltersOpen(true)}
aria-label={t('Search')}
css={css`
padding: 0;
margin-right: ${theme.sizeUnit * 2}px;
`}
>
<Icons.SearchOutlined iconSize="l" />
</Button>
) : undefined
}
/>
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
@@ -913,8 +936,14 @@ function DashboardList(props: DashboardListProps) {
? 'card'
: 'table'
}
forceViewMode={!isNotMobile ? 'card' : undefined}
enableBulkTag={enableBulkTag}
bulkTagResourceName="dashboard"
mobileFiltersOpen={mobileFiltersOpen}
setMobileFiltersOpen={
!isNotMobile ? setMobileFiltersOpen : undefined
}
mobileFiltersDrawerTitle={t('Search Dashboards')}
/>
</>
);

View File

@@ -0,0 +1,189 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Mobile-specific tests for the Home/Welcome page.
*
* These tests verify that certain desktop-only sections are hidden
* on mobile viewports.
*/
import fetchMock from 'fetch-mock';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import Welcome from 'src/pages/Home';
// Mock useBreakpoint to return MOBILE breakpoints
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Grid: {
...jest.requireActual('antd').Grid,
useBreakpoint: () => ({
xs: true,
sm: true,
md: false, // Mobile: md is false
lg: false,
xl: false,
}),
},
}));
// 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',
),
}));
// API mocks
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
const chartInfoEndpoint = 'glob:*/api/v1/chart/_info?*';
const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status?*';
const dashboardsEndpoint = 'glob:*/api/v1/dashboard/?*';
const dashboardInfoEndpoint = 'glob:*/api/v1/dashboard/_info?*';
const dashboardFavoriteStatusEndpoint =
'glob:*/api/v1/dashboard/favorite_status/?*';
const savedQueryEndpoint = 'glob:*/api/v1/saved_query/?*';
const savedQueryInfoEndpoint = 'glob:*/api/v1/saved_query/_info?*';
const recentActivityEndpoint = 'glob:*/api/v1/log/recent_activity/*';
fetchMock.get(chartsEndpoint, {
result: [
{
slice_name: 'ChartyChart',
changed_on_utc: '24 Feb 2014 10:13:14',
url: '/fakeUrl/explore',
id: '4',
table: {},
},
],
});
fetchMock.get(dashboardsEndpoint, {
result: [
{
dashboard_title: 'Dashboard_Test',
changed_on_utc: '24 Feb 2014 10:13:14',
url: '/fakeUrl/dashboard',
id: '3',
},
],
});
fetchMock.get(savedQueryEndpoint, {
result: [],
});
fetchMock.get(recentActivityEndpoint, {
result: [
{
action: 'dashboard',
item_title: "World Bank's Data",
item_type: 'dashboard',
item_url: '/superset/dashboard/world_health/',
time: 1741644942130.566,
time_delta_humanized: 'a day ago',
},
],
});
fetchMock.get(chartInfoEndpoint, { permissions: [] });
fetchMock.get(chartFavoriteStatusEndpoint, { result: [] });
fetchMock.get(dashboardInfoEndpoint, { permissions: [] });
fetchMock.get(dashboardFavoriteStatusEndpoint, { result: [] });
fetchMock.get(savedQueryInfoEndpoint, { permissions: [] });
const mockedProps = {
user: {
username: 'alpha',
firstName: 'alpha',
lastName: 'alpha',
createdOn: '2016-11-11T12:34:17',
userId: 5,
email: 'alpha@alpha.com',
isActive: true,
isAnonymous: false,
permissions: {},
roles: {
sql_lab: [['can_read', 'SavedQuery']],
},
},
};
const renderWelcome = (props = mockedProps) =>
waitFor(() => {
render(<Welcome {...props} />, {
useRedux: true,
useRouter: true,
});
});
afterEach(() => {
fetchMock.clearHistory();
});
test('Mobile view - renders Dashboards panel', async () => {
await renderWelcome();
expect(await screen.findByText('Dashboards')).toBeInTheDocument();
});
test('Mobile view - renders Recents panel', async () => {
await renderWelcome();
expect(await screen.findByText('Recents')).toBeInTheDocument();
});
test('Mobile view - does NOT render Charts panel', async () => {
await renderWelcome();
// Wait for Dashboards to ensure the component has rendered
await screen.findByText('Dashboards');
// Charts panel should NOT be present on mobile
// Look specifically for the Charts collapse panel header
const chartsPanel = screen.queryByRole('button', { name: /Charts/i });
expect(chartsPanel).not.toBeInTheDocument();
});
test('Mobile view - does NOT render Saved queries panel', async () => {
await renderWelcome();
// Wait for Dashboards to ensure the component has rendered
await screen.findByText('Dashboards');
// Saved queries panel should NOT be present on mobile
const savedQueriesPanel = screen.queryByRole('button', {
name: /Saved queries/i,
});
expect(savedQueriesPanel).not.toBeInTheDocument();
});
test('Mobile view - only shows 2 panels (Recents and Dashboards)', async () => {
await renderWelcome();
// Wait for content to load
await screen.findByText('Dashboards');
// Should only have Recents and Dashboards panels visible
// Charts and Saved queries are hidden on mobile
const recentsPanel = screen.queryByText('Recents');
const dashboardsPanel = screen.queryByText('Dashboards');
expect(recentsPanel).toBeInTheDocument();
expect(dashboardsPanel).toBeInTheDocument();
});

View File

@@ -146,6 +146,15 @@ jest.mock('@superset-ui/core', () => ({
isFeatureEnabled: jest.fn(),
}));
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Grid: {
...jest.requireActual('antd').Grid,
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
},
}));
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
const renderWelcome = (props = mockedProps) =>

View File

@@ -28,6 +28,7 @@ import { styled } from '@apache-superset/core/theme';
import rison from 'rison';
import { Collapse, ListViewCard } from '@superset-ui/core/components';
import { User } from 'src/types/bootstrapTypes';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { reject } from 'lodash-es';
import {
dangerouslyGetItemDoNotUse,
@@ -147,6 +148,7 @@ export const LoadingCards = ({ cover }: LoadingProps) => (
);
function Welcome({ user, addDangerToast }: WelcomeProps) {
const isNotMobile = !useIsMobile();
const canReadSavedQueries = userHasPermission(user, 'SavedQuery', 'can_read');
const userid = user.userId;
const id = userid!.toString(); // confident that user is not a guest user
@@ -397,24 +399,29 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
/>
),
},
{
key: 'charts',
label: t('Charts'),
children:
!chartData || isRecentActivityLoading ? (
<LoadingCards cover={checked} />
) : (
<ChartTable
showThumbnails={checked}
user={user}
mine={chartData}
otherTabData={activityData?.[TableTab.Other]}
otherTabFilters={otherTabFilters}
otherTabTitle={otherTabTitle}
/>
),
},
...(canReadSavedQueries
// Hide Charts and Saved queries on mobile - consumption-only mode
...(isNotMobile
? [
{
key: 'charts',
label: t('Charts'),
children:
!chartData || isRecentActivityLoading ? (
<LoadingCards cover={checked} />
) : (
<ChartTable
showThumbnails={checked}
user={user}
mine={chartData}
otherTabData={activityData?.[TableTab.Other]}
otherTabFilters={otherTabFilters}
otherTabTitle={otherTabTitle}
/>
),
},
]
: []),
...(isNotMobile && canReadSavedQueries
? [
{
key: 'saved-queries',

View File

@@ -0,0 +1,179 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import MobileUnsupported from './index';
// Mock useBreakpoint to return mobile by default
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Grid: {
...jest.requireActual('antd').Grid,
useBreakpoint: () => ({
xs: true,
sm: true,
md: false,
lg: false,
xl: false,
}),
},
}));
// Mock useHistory
const mockPush = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
push: mockPush,
}),
}));
// Store original sessionStorage
const originalSessionStorage = window.sessionStorage;
beforeEach(() => {
jest.clearAllMocks();
sessionStorage.clear();
});
afterEach(() => {
// Restore sessionStorage
Object.defineProperty(window, 'sessionStorage', {
value: originalSessionStorage,
writable: true,
});
});
const renderComponent = (props = {}) =>
render(
<MemoryRouter initialEntries={['/chart/list/']}>
<MobileUnsupported {...props} />
</MemoryRouter>,
);
test('renders the page title', () => {
renderComponent();
expect(
screen.getByText("This view isn't available on mobile"),
).toBeInTheDocument();
});
test('renders the description text', () => {
renderComponent();
expect(
screen.getByText(
'Some features require a larger screen. Try viewing dashboards for the best mobile experience.',
),
).toBeInTheDocument();
});
test('renders the View Dashboards button', () => {
renderComponent();
expect(
screen.getByRole('button', { name: 'View Dashboards' }),
).toBeInTheDocument();
});
test('renders the Go to Welcome Page button', () => {
renderComponent();
expect(
screen.getByRole('button', { name: 'Go to Welcome Page' }),
).toBeInTheDocument();
});
test('renders the Continue anyway link', () => {
renderComponent();
expect(screen.getByText(/Continue anyway/)).toBeInTheDocument();
});
test('View Dashboards button navigates to dashboard list', async () => {
renderComponent();
const button = screen.getByRole('button', { name: 'View Dashboards' });
await userEvent.click(button);
expect(mockPush).toHaveBeenCalledWith('/dashboard/list/');
});
test('Go to Welcome Page button navigates to welcome page', async () => {
renderComponent();
const button = screen.getByRole('button', { name: 'Go to Welcome Page' });
await userEvent.click(button);
expect(mockPush).toHaveBeenCalledWith('/welcome/');
});
test('Continue anyway sets sessionStorage and navigates to original path', async () => {
renderComponent({ originalPath: '/chart/list/' });
const link = screen.getByText(/Continue anyway/);
await userEvent.click(link);
expect(sessionStorage.getItem('mobile-bypass')).toBe('true');
expect(mockPush).toHaveBeenCalledWith('/chart/list/');
});
test('uses originalPath prop when provided', async () => {
renderComponent({ originalPath: '/explore/?form_data=123' });
const link = screen.getByText(/Continue anyway/);
await userEvent.click(link);
expect(mockPush).toHaveBeenCalledWith('/explore/?form_data=123');
});
test('handles sessionStorage errors gracefully', async () => {
// Mock sessionStorage to throw
const mockStorage = {
getItem: jest.fn(() => {
throw new Error('Storage access denied');
}),
setItem: jest.fn(() => {
throw new Error('Storage access denied');
}),
removeItem: jest.fn(),
clear: jest.fn(),
length: 0,
key: jest.fn(),
};
Object.defineProperty(window, 'sessionStorage', {
value: mockStorage,
writable: true,
});
renderComponent({ originalPath: '/chart/list/' });
const link = screen.getByText(/Continue anyway/);
// Should not throw even though sessionStorage fails
await userEvent.click(link);
// Should still navigate even if storage failed
expect(mockPush).toHaveBeenCalledWith('/chart/list/');
});
test('renders desktop icon', () => {
renderComponent();
// The icon should be present (DesktopOutlined)
// Icon might not have aria-label, so we verify the page renders with the title
expect(
screen.getByText("This view isn't available on mobile"),
).toBeInTheDocument();
});

View File

@@ -0,0 +1,196 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { t } from '@apache-superset/core/translation';
import { css, useTheme } from '@apache-superset/core/theme';
import { Button, Grid } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
const { useBreakpoint } = Grid;
/** sessionStorage key for the "Continue anyway" mobile bypass */
export const MOBILE_BYPASS_STORAGE_KEY = 'mobile-bypass';
interface MobileUnsupportedProps {
/** The original path the user was trying to access */
originalPath?: string;
}
/**
* A mobile-friendly page shown when users try to access
* features that aren't supported on mobile devices.
*/
function MobileUnsupported({ originalPath }: MobileUnsupportedProps) {
const theme = useTheme();
const history = useHistory();
const location = useLocation();
const screens = useBreakpoint();
const fromPath = originalPath || location.pathname;
const handleViewDashboards = useCallback(() => {
history.push('/dashboard/list/');
}, [history]);
const handleGoHome = useCallback(() => {
history.push('/welcome/');
}, [history]);
const handleContinueAnyway = useCallback(() => {
// Store preference in sessionStorage so we don't keep redirecting
try {
sessionStorage.setItem(MOBILE_BYPASS_STORAGE_KEY, 'true');
} catch {
// Storage access denied, continue anyway without persisting
}
history.push(fromPath);
}, [history, fromPath]);
// Determine if we're at or above the 'md' breakpoint (i.e. not on mobile)
const isNotMobile = screens.md;
return (
<div
css={css`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 60px);
padding: ${theme.sizeUnit * 6}px;
text-align: center;
background: ${theme.colorBgContainer};
`}
>
{/* Icon */}
<div
css={css`
width: 120px;
height: 120px;
border-radius: 50%;
background: ${theme.colorBgLayout};
display: flex;
align-items: center;
justify-content: center;
margin-bottom: ${theme.sizeUnit * 6}px;
`}
>
<Icons.DesktopOutlined
iconSize="xxl"
iconColor={theme.colorTextSecondary}
css={css`
font-size: 48px;
`}
/>
</div>
{/* Title */}
<h1
css={css`
font-size: ${theme.fontSizeXL}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorText};
margin: 0 0 ${theme.sizeUnit * 2}px 0;
`}
>
{t("This view isn't available on mobile")}
</h1>
{/* Description */}
<p
css={css`
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextSecondary};
margin: 0 0 ${theme.sizeUnit * 8}px 0;
max-width: 300px;
line-height: 1.5;
`}
>
{t(
'Some features require a larger screen. Try viewing dashboards for the best mobile experience.',
)}
</p>
{/* Primary action */}
<div>
<Button
buttonStyle="primary"
onClick={handleViewDashboards}
css={css`
width: 280px;
height: 48px;
font-size: ${theme.fontSizeSM}px;
`}
>
{t('View Dashboards')}
</Button>
</div>
{/* Secondary action */}
<div
css={css`
margin-top: ${theme.sizeUnit * 3}px;
`}
>
<Button
buttonStyle="secondary"
onClick={handleGoHome}
css={css`
width: 280px;
height: 48px;
font-size: ${theme.fontSizeSM}px;
`}
>
{t('Go to Welcome Page')}
</Button>
</div>
{/* Continue anyway link */}
<Button
buttonStyle="link"
onClick={handleContinueAnyway}
css={css`
margin-top: ${theme.sizeUnit * 4}px;
`}
>
{t('Continue anyway')}
<Icons.ArrowRightOutlined iconSize="s" />
</Button>
{/* Show hint if screen is now larger */}
{isNotMobile && (
<p
css={css`
margin-top: ${theme.sizeUnit * 6}px;
font-size: ${theme.fontSizeXS}px;
color: ${theme.colorTextDescription};
`}
>
{t('Your screen is now large enough.')}
<Button buttonStyle="link" onClick={handleContinueAnyway}>
{t('Continue to page')}
</Button>
</p>
)}
</div>
);
}
export default MobileUnsupported;

View File

@@ -29,6 +29,7 @@ import { css, useTheme } from '@apache-superset/core/theme';
import { Flex, Layout, Loading } from '@superset-ui/core/components';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { ErrorBoundary } from 'src/components';
import MobileRouteGuard from 'src/components/MobileRouteGuard';
import Menu from 'src/features/home/Menu';
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
@@ -91,19 +92,29 @@ const RouteSwitch = () => {
const theme = useTheme();
return (
<Switch>
{routes.map(({ path, Component, props = {}, Fallback = Loading }) => (
<Route path={path} key={path}>
<Suspense fallback={<Fallback />}>
<ErrorBoundary
css={css`
margin: ${theme.sizeUnit * 4}px;
`}
>
<Component user={bootstrapData.user} {...props} />
</ErrorBoundary>
</Suspense>
</Route>
))}
{routes.map(
({
path,
Component,
props = {},
Fallback = Loading,
mobileSupported,
}) => (
<Route path={path} key={path}>
<Suspense fallback={<Fallback />}>
<MobileRouteGuard mobileSupported={mobileSupported}>
<ErrorBoundary
css={css`
margin: ${theme.sizeUnit * 4}px;
`}
>
<Component user={bootstrapData.user} {...props} />
</ErrorBoundary>
</MobileRouteGuard>
</Suspense>
</Route>
),
)}
<Redirect from="/" to="/welcome/" exact />
</Switch>
);

View File

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

View File

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

View File

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