diff --git a/.github/workflows/superset-playwright.yml b/.github/workflows/superset-playwright.yml index a2db6444e43..36976e61c58 100644 --- a/.github/workflows/superset-playwright.yml +++ b/.github/workflows/superset-playwright.yml @@ -155,6 +155,17 @@ jobs: INCLUDE_EMBEDDED: "true" with: run: playwright-run "${{ matrix.app_root }}" embedded + - name: Run Playwright (Mobile Tests) + uses: ./.github/actions/cached-dependencies + env: + NODE_OPTIONS: "--max-old-space-size=4096" + # Scoped to this step for the same reason as the embedded flags + # above: the mobile consumption mode should not alter Flask's + # configuration for the required desktop test steps. + SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE: "true" + INCLUDE_MOBILE: "true" + with: + run: playwright-run "${{ matrix.app_root }}" mobile/ - name: Set safe app root if: failure() id: set-safe-app-root diff --git a/docker/pythonpath_dev/superset_config.py b/docker/pythonpath_dev/superset_config.py index 7755cdb2cdc..c80f845cf13 100644 --- a/docker/pythonpath_dev/superset_config.py +++ b/docker/pythonpath_dev/superset_config.py @@ -110,6 +110,7 @@ FEATURE_FLAGS = { "ALERT_REPORTS": True, "DATASET_FOLDERS": True, "ENABLE_EXTENSIONS": True, + "MOBILE_CONSUMPTION_MODE": True, "SEMANTIC_LAYERS": True, } EXTENSIONS_PATH = "/app/docker/extensions" diff --git a/docs/docs/using-superset/mobile-experience.mdx b/docs/docs/using-superset/mobile-experience.mdx new file mode 100644 index 00000000000..e62a9adb85f --- /dev/null +++ b/docs/docs/using-superset/mobile-experience.mdx @@ -0,0 +1,89 @@ +--- +title: Mobile Experience +sidebar_position: 7 +version: 1 +--- + +import useBaseUrl from "@docusaurus/useBaseUrl"; + +# Mobile Experience + +Superset ships an optional, consumption-only mobile experience for viewing +dashboards on phones and other small screens. When enabled, screens below +768px wide get a layout built for touch: dashboards render their charts +stacked full-width, navigation collapses into a drawer, and dashboard +filters open in a slide-out panel. + +The mobile experience is **read-only by design**. It is aimed at consumers +of analytics — people checking a dashboard from a phone — not at dashboard +authors. Authoring surfaces (chart builder, SQL Lab, dataset management, +and administrative screens) remain desktop-only. + +## Enabling the mobile experience + +The mobile experience is gated behind the `MOBILE_CONSUMPTION_MODE` feature +flag, which is off by default. Enable it in your `superset_config.py`: + +```python +FEATURE_FLAGS = { + "MOBILE_CONSUMPTION_MODE": True, +} +``` + +With the flag disabled, Superset renders identically at every screen size, +and phones display the desktop layout scaled down (the pre-existing +behavior). The flag also controls whether Superset serves a viewport meta +tag, which is required for mobile browsers to apply the responsive layout +at their native width. + +## What works on mobile + +| Area | Mobile behavior | +| --- | --- | +| **Dashboards** | Charts stack vertically at full width, sized to the screen. Tab bars are sticky and swipeable. Native filters open in a drawer via the filter icon in the header. | +| **Dashboard list** | Card view with full-width cards; search and filters open in a drawer. | +| **Home** | Recents (dashboards only) and dashboard cards; desktop-only sections are hidden. | +| **Navigation** | A hamburger menu opens a drawer with links to dashboards, theme and language selection, and user info/logout. | + +
+ A dashboard on mobile with charts stacked full width + The dashboard filter drawer on mobile + The dashboard list in card view on mobile +
+ +
+ The Superset home page on mobile + The mobile navigation drawer + The screen shown for views that are not available on mobile +
+ +## What doesn't work on mobile + +Everything not listed above shows a friendly "This view isn't available on +mobile" screen with shortcuts back to dashboards and the home page. That +includes: + +- Chart builder (Explore) and chart-level links — chart titles on + dashboards are plain text on mobile, and chart entries are filtered out + of the home page's Recents feed +- SQL Lab and query history +- Creating or editing dashboards, charts, datasets, and databases +- List views other than dashboards (charts, datasets, saved queries, etc.) +- Administrative and settings screens + +Editing controls are also removed from the screens that *are* supported: +the dashboard header hides the edit, publish, and favorite controls, and +dashboard/chart kebab menus are reduced to view-oriented actions. + +If a device crosses the 768px threshold — for example, rotating a tablet +to landscape or resizing a window — the full desktop experience becomes +available immediately. + +## Notes for operators + +- The flag is deployment-wide; there is no per-role or per-user targeting. +- Dashboard permalinks and links shared from desktop resolve normally on + mobile as long as they point at dashboards. +- Embedded dashboards are unaffected: the embedded SDK controls its own + layout, and the viewport meta tag is only interpreted by the top-level + page. diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json index 5646315efd9..4880b2a982b 100644 --- a/docs/static/feature-flags.json +++ b/docs/static/feature-flags.json @@ -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, diff --git a/docs/static/img/screenshots/mobile/mobile_dashboard.jpg b/docs/static/img/screenshots/mobile/mobile_dashboard.jpg new file mode 100644 index 00000000000..dda0c671d62 Binary files /dev/null and b/docs/static/img/screenshots/mobile/mobile_dashboard.jpg differ diff --git a/docs/static/img/screenshots/mobile/mobile_dashboard_list.jpg b/docs/static/img/screenshots/mobile/mobile_dashboard_list.jpg new file mode 100644 index 00000000000..e7d16911b87 Binary files /dev/null and b/docs/static/img/screenshots/mobile/mobile_dashboard_list.jpg differ diff --git a/docs/static/img/screenshots/mobile/mobile_filter_drawer.jpg b/docs/static/img/screenshots/mobile/mobile_filter_drawer.jpg new file mode 100644 index 00000000000..0565001b388 Binary files /dev/null and b/docs/static/img/screenshots/mobile/mobile_filter_drawer.jpg differ diff --git a/docs/static/img/screenshots/mobile/mobile_home.jpg b/docs/static/img/screenshots/mobile/mobile_home.jpg new file mode 100644 index 00000000000..c1293b5c8d9 Binary files /dev/null and b/docs/static/img/screenshots/mobile/mobile_home.jpg differ diff --git a/docs/static/img/screenshots/mobile/mobile_nav_drawer.jpg b/docs/static/img/screenshots/mobile/mobile_nav_drawer.jpg new file mode 100644 index 00000000000..e987c39876c Binary files /dev/null and b/docs/static/img/screenshots/mobile/mobile_nav_drawer.jpg differ diff --git a/docs/static/img/screenshots/mobile/mobile_unsupported.jpg b/docs/static/img/screenshots/mobile/mobile_unsupported.jpg new file mode 100644 index 00000000000..5d645606c1e Binary files /dev/null and b/docs/static/img/screenshots/mobile/mobile_unsupported.jpg differ diff --git a/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/index.tsx index a465941ad8b..5e100edfd87 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/index.tsx @@ -49,6 +49,7 @@ const titleStyles = (theme: SupersetTheme) => css` text-overflow: ellipsis; white-space: nowrap; padding: 0; + font-weight: inherit; color: ${theme.colorText}; background-color: ${theme.colorBgContainer}; @@ -127,6 +128,21 @@ export const DynamicEditableTitle = memo( } }, [currentTitle, placeholder]); + // Webfont metrics differ from the fallback font's, so a measurement + // taken before fonts finish loading under- or over-sizes the input. + // Re-measure once all fonts are ready. + useEffect(() => { + let cancelled = false; + document.fonts?.ready?.then(() => { + if (!cancelled && sizerRef.current) { + setInputWidth(sizerRef.current.offsetWidth); + } + }); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { const inputElement = inputRef.current?.input; diff --git a/superset-frontend/packages/superset-ui-core/src/components/PageHeaderWithActions/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/PageHeaderWithActions/index.tsx index d536f292bea..cd1ef78f834 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/PageHeaderWithActions/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/PageHeaderWithActions/index.tsx @@ -20,6 +20,7 @@ import { ReactNode, ReactElement, memo } from 'react'; import { t } from '@apache-superset/core/translation'; import { css, SupersetTheme, useTheme } from '@apache-superset/core/theme'; import { Icons } from '@superset-ui/core/components/Icons'; +import { FeatureFlag, isFeatureEnabled } from '../../utils/featureFlags'; import type { DropdownProps } from '../Dropdown/types'; import type { TooltipPlacement } from '../Tooltip/types'; import type { CertifiedBadgeProps } from '../CertifiedBadge/types'; @@ -82,6 +83,20 @@ const headerStyles = (theme: SupersetTheme) => css` display: flex; align-items: center; } + + /* Mobile consumption mode: center the title between left/right panels */ + ${ + isFeatureEnabled(FeatureFlag.MobileConsumptionMode) && + css` + @media (max-width: ${theme.screenSMMax}px) { + .title-panel { + flex: 1; + justify-content: center; + margin-right: 0; + } + } + ` + } `; const buttonsStyles = (theme: SupersetTheme) => css` @@ -109,6 +124,7 @@ export type PageHeaderWithActionsProps = { showFaveStar: boolean; showMenuDropdown?: boolean; faveStarProps: FaveStarProps; + leftPanelItems?: ReactNode; titlePanelAdditionalItems: ReactNode; rightPanelAdditionalItems: ReactNode; additionalActionsMenu: ReactElement; @@ -126,6 +142,7 @@ export const PageHeaderWithActions = memo( certificatiedBadgeProps, showFaveStar, faveStarProps, + leftPanelItems, titlePanelAdditionalItems, rightPanelAdditionalItems, additionalActionsMenu, @@ -136,6 +153,7 @@ export const PageHeaderWithActions = memo( const theme = useTheme(); return (
+ {leftPanelItems}
{showTitlePanelItems && ( diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index 84ff7ce59f8..9477c18eab5 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -57,6 +57,7 @@ export enum FeatureFlag { GranularExportControls = 'GRANULAR_EXPORT_CONTROLS', ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW', Matrixify = 'MATRIXIFY', + MobileConsumptionMode = 'MOBILE_CONSUMPTION_MODE', ScheduledQueries = 'SCHEDULED_QUERIES', SemanticLayers = 'SEMANTIC_LAYERS', SqllabBackendPersistence = 'SQLLAB_BACKEND_PERSISTENCE', diff --git a/superset-frontend/playwright.config.ts b/superset-frontend/playwright.config.ts index dccb1d65141..6b67bb3975b 100644 --- a/superset-frontend/playwright.config.ts +++ b/superset-frontend/playwright.config.ts @@ -96,6 +96,7 @@ export default defineConfig({ '**/tests/auth/**/*.spec.ts', '**/tests/sqllab/**/*.spec.ts', '**/tests/embedded/**/*.spec.ts', + '**/tests/mobile/**/*.spec.ts', ...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']), ], use: { @@ -156,6 +157,23 @@ export default defineConfig({ }, ] : []), + // Mobile consumption-mode tests need the MOBILE_CONSUMPTION_MODE feature + // flag enabled in the Flask backend (the workflow's mobile step sets + // SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE), so they only run when the + // environment opts in. Same strict 'true' check as INCLUDE_EMBEDDED. + ...(process.env.INCLUDE_MOBILE?.toLowerCase() === 'true' + ? [ + { + name: 'chromium-mobile', + testMatch: '**/tests/mobile/**/*.spec.ts', + use: { + browserName: 'chromium' as const, + testIdAttribute: 'data-test', + storageState: 'playwright/.auth/user.json', + }, + }, + ] + : []), ], // Web server setup - disabled in CI (Flask started separately in workflow) diff --git a/superset-frontend/playwright/generators/docs/mobile-screenshots.spec.ts b/superset-frontend/playwright/generators/docs/mobile-screenshots.spec.ts new file mode 100644 index 00000000000..119d3a1a5e5 --- /dev/null +++ b/superset-frontend/playwright/generators/docs/mobile-screenshots.spec.ts @@ -0,0 +1,167 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Mobile Experience Documentation Screenshot Generator + * + * Captures phone-sized screenshots for the mobile consumption mode docs + * (docs/docs/using-superset/mobile-experience.mdx). Depends on example data + * loaded via `superset load_examples` AND the MOBILE_CONSUMPTION_MODE + * feature flag being enabled in the target environment: + * + * FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True} + * + * Run locally: + * cd superset-frontend + * PLAYWRIGHT_BASE_URL=http://localhost:8088 PLAYWRIGHT_ADMIN_PASSWORD=admin npm run docs:screenshots + * + * Screenshots are saved under docs/static/img/screenshots/mobile/. + */ + +import path from 'path'; +import { Page, test, expect } from '@playwright/test'; +import { URL } from '../../utils/urls'; + +const MOBILE_SCREENSHOTS_DIR = path.resolve( + __dirname, + '../../../../docs/static/img/screenshots/mobile', +); + +// iPhone 12-class viewport; 2x scale factor for crisp docs images +test.use({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 2, + hasTouch: true, +}); + +/** + * Waits for animations and async renders to settle before taking a + * screenshot. ECharts entry animations, drawer transitions, and image + * lazy-loading require a short pause that can't be expressed as a + * deterministic wait condition. + */ +async function settle(page: Page, ms = 1000): Promise { + await page.waitForTimeout(ms); +} + +/** + * Opens the Sales Dashboard (from example data) at phone size and waits for + * the stacked charts to finish rendering. + */ +async function openSalesDashboardMobile(page: Page): Promise { + await page.goto(URL.DASHBOARD_LIST); + // Mobile list is card-only; cards navigate on tap (titles are plain + // text, not links, in consumption mode) + const dashboardCard = page.getByText('Sales Dashboard', { exact: true }); + await expect(dashboardCard.first()).toBeVisible({ timeout: 15000 }); + await dashboardCard.first().click(); + + await expect( + page.locator('[data-test="dashboard-content-wrapper"]'), + ).toBeVisible({ timeout: 30000 }); + await expect( + page.locator('.dashboard-component-chart-holder canvas').first(), + ).toBeVisible({ timeout: 30000 }); +} + +test('mobile dashboard screenshot', async ({ page }) => { + await openSalesDashboardMobile(page); + await settle(page, 2000); + await page.screenshot({ + path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_dashboard.jpg'), + type: 'jpeg', + }); +}); + +test('mobile dashboard filter drawer screenshot', async ({ page }) => { + await openSalesDashboardMobile(page); + + const filterTrigger = page.locator('[data-test="mobile-filters-trigger"]'); + await expect(filterTrigger).toBeVisible({ timeout: 15000 }); + await filterTrigger.click(); + + // Wait for the drawer and its filter controls to render + await expect(page.locator('.ant-drawer-body')).toBeVisible({ + timeout: 10000, + }); + await expect(page.locator('[data-test="filter-bar"]')).toBeVisible({ + timeout: 10000, + }); + // Park the pointer so no hover card is open in the capture + await page.mouse.move(5, 830); + await settle(page); + await page.screenshot({ + path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_filter_drawer.jpg'), + type: 'jpeg', + }); +}); + +test('mobile dashboard list screenshot', async ({ page }) => { + await page.goto(URL.DASHBOARD_LIST); + // Card view is forced on mobile; wait for cards to render + await expect(page.locator('[data-test="styled-card"]').first()).toBeVisible({ + timeout: 15000, + }); + await settle(page); + await page.screenshot({ + path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_dashboard_list.jpg'), + type: 'jpeg', + }); +}); + +test('mobile home screenshot', async ({ page }) => { + await page.goto(URL.WELCOME); + await expect(page.getByText('Recents')).toBeVisible({ timeout: 15000 }); + await settle(page, 2000); + await page.screenshot({ + path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_home.jpg'), + type: 'jpeg', + }); +}); + +test('mobile navigation drawer screenshot', async ({ page }) => { + await page.goto(URL.WELCOME); + await expect(page.getByText('Recents')).toBeVisible({ timeout: 15000 }); + + const menuButton = page.getByRole('button', { name: 'Menu' }); + await expect(menuButton).toBeVisible({ timeout: 10000 }); + await menuButton.click(); + + await expect(page.locator('.ant-drawer-body')).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByText('Dashboards').first()).toBeVisible(); + await settle(page); + await page.screenshot({ + path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_nav_drawer.jpg'), + type: 'jpeg', + }); +}); + +test('mobile unsupported route screenshot', async ({ page }) => { + await page.goto(URL.SQLLAB); + await expect( + page.getByText("This view isn't available on mobile"), + ).toBeVisible({ timeout: 15000 }); + await settle(page); + await page.screenshot({ + path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_unsupported.jpg'), + type: 'jpeg', + }); +}); diff --git a/superset-frontend/playwright/tests/mobile/mobile-dashboard.spec.ts b/superset-frontend/playwright/tests/mobile/mobile-dashboard.spec.ts new file mode 100644 index 00000000000..cce973d2017 --- /dev/null +++ b/superset-frontend/playwright/tests/mobile/mobile-dashboard.spec.ts @@ -0,0 +1,325 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { test, expect, devices } from '@playwright/test'; + +// NOTE: These tests exercise the mobile consumption experience and require +// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target +// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}). +import { TIMEOUT } from '../../utils/constants'; + +/** + * Mobile dashboard viewing tests verify that dashboards can be viewed + * and interacted with on mobile devices. + * + * These tests assume the World Bank's Health sample dashboard exists. + */ + +// Use iPhone 12 viewport for mobile tests +const mobileViewport = devices['iPhone 12']; + +test.describe('Mobile Dashboard Viewing', () => { + test.use({ + viewport: mobileViewport.viewport, + userAgent: mobileViewport.userAgent, + }); + + test.beforeEach(async ({ page }) => { + // Navigate to dashboard list to find a dashboard + await page.goto('dashboard/list/'); + await page.waitForLoadState('networkidle'); + }); + + test('dashboard list renders in card view on mobile', async ({ page }) => { + // On mobile, dashboard list should show cards, not table + // Look for card elements + const cards = page.locator('[data-test="styled-card"]'); + + // Should have at least one card if dashboards exist + // (This test may need adjustment based on test data availability) + const cardCount = await cards.count(); + + // Either cards are visible, or the empty state is shown; the table + // view must never render on mobile + if (cardCount > 0) { + await expect(cards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + } else { + await expect(page.locator('[data-test="empty-state"]')).toBeVisible({ + timeout: TIMEOUT.PAGE_LOAD, + }); + } + await expect(page.locator('[data-test="listview-table"]')).toHaveCount(0); + }); + + test('mobile search button appears in dashboard list', async ({ page }) => { + // On mobile, the search/filter button should appear in the header + const searchButton = page + .locator('[aria-label="Search"]') + .or(page.locator('[data-test="mobile-search-button"]')); + + // Search button should be visible on mobile + await expect(searchButton.first()).toBeVisible({ + timeout: TIMEOUT.PAGE_LOAD, + }); + }); + + test('tapping dashboard card opens the dashboard', async ({ page }) => { + // Find a dashboard card + const cards = page.locator('[data-test="styled-card"]'); + const cardCount = await cards.count(); + + if (cardCount > 0) { + // Click the first card + await cards.first().click(); + + // Should navigate to dashboard view + await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), { + timeout: TIMEOUT.PAGE_LOAD, + }); + + // Dashboard should load (look for dashboard content) + await expect( + page + .locator('[data-test="dashboard-content-wrapper"]') + .or(page.locator('.dashboard')), + ).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + } else { + test.skip(); + } + }); +}); + +test.describe('Mobile Dashboard Interaction', () => { + test.use({ + viewport: mobileViewport.viewport, + userAgent: mobileViewport.userAgent, + }); + + // Skip this test suite if no dashboards exist + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage({ + viewport: mobileViewport.viewport, + userAgent: mobileViewport.userAgent, + }); + + await page.goto('dashboard/list/'); + await page.waitForLoadState('networkidle'); + + const cards = page.locator('[data-test="styled-card"]'); + const cardCount = await cards.count(); + + await page.close(); + + if (cardCount === 0) { + test.skip(); + } + }); + + test('dashboard loads and shows charts on mobile', async ({ page }) => { + // Navigate to dashboard list + await page.goto('dashboard/list/'); + await page.waitForLoadState('networkidle'); + + // Click first dashboard + const cards = page.locator('[data-test="styled-card"]'); + const cardCount = await cards.count(); + + if (cardCount > 0) { + await cards.first().click(); + + // Wait for dashboard to load + await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), { + timeout: TIMEOUT.PAGE_LOAD, + }); + + // Dashboard content should be visible + await expect( + page + .locator('[data-test="dashboard-content-wrapper"]') + .or(page.locator('.dashboard')), + ).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + + // Charts should start loading (look for chart containers) + const chartContainers = page + .locator('[data-test="chart-container"]') + .or(page.locator('.dashboard-chart')); + + // Wait for at least one chart to be visible (with timeout) + await expect(chartContainers.first()).toBeVisible({ + timeout: TIMEOUT.PAGE_LOAD * 2, + }); + } + }); + + test('dashboard header shows hamburger menu on mobile', async ({ page }) => { + // Navigate to dashboard list + await page.goto('dashboard/list/'); + await page.waitForLoadState('networkidle'); + + // Click first dashboard + const cards = page.locator('[data-test="styled-card"]'); + const cardCount = await cards.count(); + + if (cardCount > 0) { + await cards.first().click(); + + // Wait for dashboard + await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), { + timeout: TIMEOUT.PAGE_LOAD, + }); + + // Look for the hamburger menu / more actions button + const menuButton = page + .locator('[data-test="actions-trigger"]') + .or(page.locator('[aria-label="Menu actions trigger"]')); + + await expect(menuButton.first()).toBeVisible({ + timeout: TIMEOUT.PAGE_LOAD, + }); + } + }); + + test('refresh dashboard works from mobile menu', async ({ page }) => { + // Navigate to dashboard list + await page.goto('dashboard/list/'); + await page.waitForLoadState('networkidle'); + + // Click first dashboard + const cards = page.locator('[data-test="styled-card"]'); + const cardCount = await cards.count(); + + if (cardCount > 0) { + await cards.first().click(); + + // Wait for dashboard + await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), { + timeout: TIMEOUT.PAGE_LOAD, + }); + + // Open the actions menu + const menuButton = page + .locator('[data-test="actions-trigger"]') + .or(page.locator('[aria-label="Menu actions trigger"]')); + + if ((await menuButton.count()) > 0) { + await menuButton.first().click(); + + // Look for refresh option + const refreshOption = page.getByText('Refresh dashboard'); + + if ((await refreshOption.count()) > 0) { + await refreshOption.click(); + + // Should show success toast or refresh the charts + // This is hard to verify without checking network requests + // Just verify the menu closes and we're still on the dashboard + await page.waitForTimeout(1000); + expect(page.url()).toMatch(/\/dashboard\/(?!list)/); + } + } + } + }); +}); + +test.describe('Mobile Filter Drawer', () => { + test.use({ + viewport: mobileViewport.viewport, + userAgent: mobileViewport.userAgent, + }); + + test('filter button appears on dashboards with filters', async ({ page }) => { + // Navigate to dashboard list + await page.goto('dashboard/list/'); + await page.waitForLoadState('networkidle'); + + // Click first dashboard + const cards = page.locator('[data-test="styled-card"]'); + const cardCount = await cards.count(); + + if (cardCount > 0) { + await cards.first().click(); + + // Wait for dashboard + await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), { + timeout: TIMEOUT.PAGE_LOAD, + }); + + // Give filters time to load + await page.waitForTimeout(2000); + + // Check for filter button (only visible if dashboard has filters) + const filterButton = page + .locator('[data-test="filter-icon"]') + .or( + page + .locator('[aria-label="Filters"]') + .or(page.locator('.mobile-filter-button')), + ); + + const filterCount = await filterButton.count(); + + // The test passes whether filters exist or not + // If filters exist, button should be visible + // If no filters, that's also valid + if (filterCount > 0) { + await expect(filterButton.first()).toBeVisible(); + } + } + }); + + test('filter drawer opens when filter button is tapped', async ({ page }) => { + // Navigate to dashboard list + await page.goto('dashboard/list/'); + await page.waitForLoadState('networkidle'); + + // Click first dashboard + const cards = page.locator('[data-test="styled-card"]'); + const cardCount = await cards.count(); + + if (cardCount > 0) { + await cards.first().click(); + + // Wait for dashboard + await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), { + timeout: TIMEOUT.PAGE_LOAD, + }); + + // Give filters time to load + await page.waitForTimeout(2000); + + // Check for filter button + const filterButton = page + .locator('[data-test="filter-icon"]') + .or(page.locator('[aria-label="Filters"]')); + + if ((await filterButton.count()) > 0) { + await filterButton.first().click(); + + // Filter drawer should open + const drawer = page + .locator('.ant-drawer-open') + .or(page.locator('[data-test="filter-bar"]')); + + await expect(drawer.first()).toBeVisible({ + timeout: TIMEOUT.FORM_LOAD, + }); + } + } + }); +}); diff --git a/superset-frontend/playwright/tests/mobile/mobile-navigation.spec.ts b/superset-frontend/playwright/tests/mobile/mobile-navigation.spec.ts new file mode 100644 index 00000000000..aec3f325e9d --- /dev/null +++ b/superset-frontend/playwright/tests/mobile/mobile-navigation.spec.ts @@ -0,0 +1,192 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { test, expect, devices } from '@playwright/test'; + +// NOTE: These tests exercise the mobile consumption experience and require +// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target +// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}). +import { URL } from '../../utils/urls'; +import { TIMEOUT } from '../../utils/constants'; + +/** + * Mobile navigation tests verify the MobileRouteGuard behavior + * and mobile-specific navigation patterns. + * + * These tests run with a mobile viewport to trigger mobile-specific behavior. + */ + +// Use iPhone 12 viewport for mobile tests +const mobileViewport = devices['iPhone 12']; + +test.describe('Mobile Navigation', () => { + test.use({ + viewport: mobileViewport.viewport, + userAgent: mobileViewport.userAgent, + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('mobile viewport redirects from chart list to MobileUnsupported page', async ({ + page, + }) => { + // Navigate to chart list (not mobile-supported) + await page.goto(URL.CHART_LIST); + + // Should show the MobileUnsupported page + await expect( + page.getByText("This view isn't available on mobile"), + ).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + + // Primary action buttons should be visible + await expect( + page.getByRole('button', { name: 'View Dashboards' }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Go to Welcome Page' }), + ).toBeVisible(); + }); + + test('mobile viewport allows access to dashboard list', async ({ page }) => { + // Navigate to dashboard list (mobile-supported) + await page.goto(URL.DASHBOARD_LIST); + + // Should NOT show MobileUnsupported page + await expect( + page.getByText("This view isn't available on mobile"), + ).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD }); + + // Should show dashboard list content (look for dashboard list elements) + await expect( + page + .locator('[data-test="listview-table"]') + .or(page.locator('[data-test="styled-card"]')) + .first(), + ).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + }); + + test('mobile viewport allows access to welcome page', async ({ page }) => { + // Navigate to welcome page (mobile-supported) + await page.goto(URL.WELCOME); + + // Should NOT show MobileUnsupported page + await expect( + page.getByText("This view isn't available on mobile"), + ).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD }); + + // Should show welcome page content + await expect( + page.getByText('Recents').or(page.getByText('Dashboards')).first(), + ).toBeVisible({ + timeout: TIMEOUT.PAGE_LOAD, + }); + }); + + test('View Dashboards button navigates to dashboard list', async ({ + page, + }) => { + // Navigate to unsupported route + await page.goto(URL.CHART_LIST); + + // Wait for MobileUnsupported page + await expect( + page.getByText("This view isn't available on mobile"), + ).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + + // Click View Dashboards button + await page.getByRole('button', { name: 'View Dashboards' }).click(); + + // Should navigate to dashboard list + await page.waitForURL(url => url.pathname.includes('dashboard/list'), { + timeout: TIMEOUT.PAGE_LOAD, + }); + + // Dashboard list should be accessible + await expect( + page + .locator('[data-test="listview-table"]') + .or(page.locator('[data-test="styled-card"]')) + .first(), + ).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + }); + + test('Go to Welcome Page button navigates to welcome', async ({ page }) => { + // Navigate to unsupported route + await page.goto(URL.CHART_LIST); + + // Wait for MobileUnsupported page + await expect( + page.getByText("This view isn't available on mobile"), + ).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + + // Click Go to Welcome Page button + await page.getByRole('button', { name: 'Go to Welcome Page' }).click(); + + // Should navigate to welcome page + await page.waitForURL(url => url.pathname.includes('welcome'), { + timeout: TIMEOUT.PAGE_LOAD, + }); + }); + + test('unsupported screen offers no bypass', async ({ page }) => { + // The "Continue anyway" bypass was removed: desktop views are unusable + // at phone width, and growing the viewport unblocks routes automatically + await page.goto(URL.CHART_LIST); + + await expect( + page.getByText("This view isn't available on mobile"), + ).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + + await expect(page.getByText('Continue anyway')).toHaveCount(0); + }); + + test('SQL Lab is not accessible on mobile', async ({ page }) => { + // Navigate to SQL Lab (not mobile-supported) + await page.goto(URL.SQLLAB); + + // Should show the MobileUnsupported page + await expect( + page.getByText("This view isn't available on mobile"), + ).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + }); +}); + +test.describe('Desktop Navigation (control group)', () => { + // Use default desktop viewport + + test('desktop viewport allows access to all routes', async ({ page }) => { + // Navigate to chart list + await page.goto(URL.CHART_LIST); + + // Should NOT show MobileUnsupported page + await expect( + page.getByText("This view isn't available on mobile"), + ).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD }); + + // Should show chart list content + await expect( + page + .locator('[data-test="listview-table"]') + .or(page.locator('[data-test="styled-card"]')) + .first(), + ).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD }); + }); +}); diff --git a/superset-frontend/spec/helpers/mobileTestUtils.ts b/superset-frontend/spec/helpers/mobileTestUtils.ts new file mode 100644 index 00000000000..5aabcac0c1f --- /dev/null +++ b/superset-frontend/spec/helpers/mobileTestUtils.ts @@ -0,0 +1,150 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Mobile testing utilities for Jest tests. + * + * Note: We mock 'antd' directly rather than '@superset-ui/core/components' because + * mocking the latter causes circular dependency issues with ActionButton during + * jest.requireActual evaluation. Since Grid is re-exported from antd, mocking + * antd at the source works correctly. + */ + +import { FeatureFlag } from '@superset-ui/core'; + +/** + * Standard mobile breakpoint values (below md breakpoint) + */ +export const mobileBreakpoints = { + xs: true, + sm: true, + md: false, + lg: false, + xl: false, + xxl: false, +}; + +/** + * Standard desktop breakpoint values (at or above md breakpoint) + */ +export const desktopBreakpoints = { + xs: true, + sm: true, + md: true, + lg: true, + xl: true, + xxl: true, +}; + +/** + * Creates a mock for antd Grid.useBreakpoint that returns mobile breakpoints. + * Use this at the top of test files that need to simulate mobile viewport. + * + * @example + * jest.mock('antd', () => mockAntdWithMobileBreakpoint()); + */ +export const mockAntdWithMobileBreakpoint = () => ({ + ...jest.requireActual('antd'), + Grid: { + ...jest.requireActual('antd').Grid, + useBreakpoint: () => mobileBreakpoints, + }, +}); + +/** + * Creates a mock for antd Grid.useBreakpoint that returns desktop breakpoints. + * Use this at the top of test files that need to simulate desktop viewport. + * + * @example + * jest.mock('antd', () => mockAntdWithDesktopBreakpoint()); + */ +export const mockAntdWithDesktopBreakpoint = () => ({ + ...jest.requireActual('antd'), + Grid: { + ...jest.requireActual('antd').Grid, + useBreakpoint: () => desktopBreakpoints, + }, +}); + +/** + * Mocks window.matchMedia so `(max-width: ...)` queries match, simulating + * a mobile viewport for the useIsMobile hook. Returns a cleanup function + * restoring the previous matchMedia. Mobile behavior requires BOTH this + * AND the MOBILE_CONSUMPTION_MODE flag (see enableMobileConsumptionFlag). + */ +export const mockMobileMatchMedia = () => { + const previous = window.matchMedia; + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches: query.includes('max-width'), + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + return () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: previous, + }); + }; +}; + +/** + * Enables the MOBILE_CONSUMPTION_MODE feature flag on window.featureFlags. + * Mobile behavior requires BOTH a small viewport (mockMobileMatchMedia) + * AND this flag; call this in beforeAll/beforeEach of mobile test suites. + * Returns a cleanup function restoring the previous flags. + */ +export const enableMobileConsumptionFlag = () => { + const previous = window.featureFlags; + window.featureFlags = { + ...window.featureFlags, + [FeatureFlag.MobileConsumptionMode]: true, + }; + return () => { + window.featureFlags = previous; + }; +}; + +/** + * Common mobile viewport dimensions for reference + */ +export const mobileViewports = { + iPhoneX: { width: 375, height: 812 }, + iPhoneSE: { width: 375, height: 667 }, + iPhone12Pro: { width: 390, height: 844 }, + pixel5: { width: 393, height: 851 }, + samsungGalaxyS20: { width: 360, height: 800 }, +}; + +/** + * Common tablet viewport dimensions for reference + */ +export const tabletViewports = { + iPadMini: { width: 768, height: 1024 }, + iPadAir: { width: 820, height: 1180 }, + iPadPro11: { width: 834, height: 1194 }, + surfacePro7: { width: 912, height: 1368 }, +}; diff --git a/superset-frontend/src/components/ListView/CardCollection.tsx b/superset-frontend/src/components/ListView/CardCollection.tsx index b77e9f475f4..d66dbb10b8c 100644 --- a/superset-frontend/src/components/ListView/CardCollection.tsx +++ b/superset-frontend/src/components/ListView/CardCollection.tsx @@ -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; + }` + : '' + } `} `; diff --git a/superset-frontend/src/components/ListView/ListView.test.tsx b/superset-frontend/src/components/ListView/ListView.test.tsx index 2b10a1245d1..6be8e8a6952 100644 --- a/superset-frontend/src/components/ListView/ListView.test.tsx +++ b/superset-frontend/src/components/ListView/ListView.test.tsx @@ -386,3 +386,227 @@ describe('ListView', () => { expect(screen.getByTestId('empty-state')).toHaveClass('card'); }); }); + +// Mobile support tests +test('respects forceViewMode prop and hides view toggle', () => { + // Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort + const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } = + mockedPropsComprehensive; + render( + + +
Card
} + forceViewMode="card" + /> +
+
, + { store: mockStore() }, + ); + + // View toggle should not be present when forceViewMode is set + expect(screen.queryByLabelText('card-view')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('list-view')).not.toBeInTheDocument(); +}); + +test('shows card view when forceViewMode is card', () => { + // Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort + const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } = + mockedPropsComprehensive; + render( + + +
Card Content
} + forceViewMode="card" + /> +
+
, + { store: mockStore() }, + ); + + // Should render cards, not table rows + expect(screen.getAllByTestId('test-card')).toHaveLength(2); +}); + +test('renders mobile filter drawer when mobileFiltersOpen is true', () => { + const setMobileFiltersOpen = jest.fn(); + // Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort + const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } = + mockedPropsComprehensive; + render( + + + + + , + { store: mockStore() }, + ); + + // Drawer should be visible with custom title + expect(screen.getByText('Search Dashboards')).toBeInTheDocument(); +}); + +test('calls setMobileFiltersOpen(false) when drawer is closed', async () => { + const setMobileFiltersOpen = jest.fn(); + // Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort + const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } = + mockedPropsComprehensive; + render( + + + + + , + { store: mockStore() }, + ); + + // Click the close button on the drawer + const closeButton = screen.getByLabelText('Close'); + await userEvent.click(closeButton); + + expect(setMobileFiltersOpen).toHaveBeenCalledWith(false); +}); + +test('mobile drawer contains FilterControls', () => { + const setMobileFiltersOpen = jest.fn(); + const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } = + mockedPropsComprehensive; + render( + + + + + , + { 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( + + +
Card
} + forceViewMode="card" + mobileFiltersOpen + setMobileFiltersOpen={setMobileFiltersOpen} + initialSort={[{ id: 'something' }]} + /> +
+
, + { store: mockStore() }, + ); + + // Sort select should be present (may be multiple - one in drawer, one in header) + const sortSelects = screen.getAllByTestId('card-sort-select'); + expect(sortSelects.length).toBeGreaterThan(0); +}); + +test('uses default drawer title when mobileFiltersDrawerTitle not provided', () => { + const setMobileFiltersOpen = jest.fn(); + const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } = + mockedPropsComprehensive; + render( + + + + + , + { store: mockStore() }, + ); + + // Default title should be 'Search' + expect(screen.getByText('Search')).toBeInTheDocument(); +}); + +test('does not render drawer when mobileFiltersOpen is false', () => { + const setMobileFiltersOpen = jest.fn(); + const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } = + mockedPropsComprehensive; + render( + + + + + , + { store: mockStore() }, + ); + + // Drawer should not be visible (title not in visible content) + // Note: Ant Design drawer might still be in DOM but hidden + const drawer = document.querySelector('.ant-drawer-open'); + expect(drawer).toBeNull(); +}); + +test('does not render mobile drawer without setMobileFiltersOpen prop', () => { + const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } = + mockedPropsComprehensive; + render( + + + + + , + { store: mockStore() }, + ); + + // No drawer elements should exist + const drawer = document.querySelector('.ant-drawer'); + expect(drawer).toBeNull(); +}); + +test('forceViewMode table shows table view', () => { + const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } = + mockedPropsComprehensive; + render( + + +
Card
} + forceViewMode="table" + /> +
+
, + { store: mockStore() }, + ); + + // Should show table, not cards + expect(screen.queryByTestId('card')).not.toBeInTheDocument(); + // Table should be present + expect(screen.getByRole('table')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index f8129b50009..dfeb1e27da0 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -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 { renderCard?: (row: T & { loading: boolean }) => ReactNode; cardSortSelectOptions?: Array; defaultViewMode?: ViewModeType; + forceViewMode?: ViewModeType; highlightRowId?: number; showThumbnails?: boolean; emptyState?: EmptyStateProps; @@ -320,6 +346,12 @@ export interface ListViewProps { expandable?: Record; /** 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({ @@ -341,6 +373,7 @@ export function ListView({ showThumbnails, cardSortSelectOptions, defaultViewMode = 'card', + forceViewMode, highlightRowId, emptyState, columnsForWrapText, @@ -351,6 +384,9 @@ export function ListView({ headerContent, addSuccessToast, addDangerToast, + mobileFiltersOpen = false, + setMobileFiltersOpen, + mobileFiltersDrawerTitle, }: ListViewProps) { const { getTableProps, @@ -377,6 +413,7 @@ export function ListView({ initialFilters: filters, renderCard: Boolean(renderCard), defaultViewMode, + forceViewMode, }); const allowBulkTagActions = bulkTagResourceName && enableBulkTag; const filterable = Boolean(filters.length); @@ -453,11 +490,15 @@ export function ListView({ )}
- {cardViewEnabled && ( + {cardViewEnabled && !forceViewMode && ( )}
- {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 && ( ({ updateFilterValue={applyFilterValue} /> )} - {viewMode === 'card' && cardSortSelectOptions && ( - setSortBy(value)} - options={cardSortSelectOptions} - /> - )} - {filterable && ( + {viewMode === 'card' && + cardSortSelectOptions && + !setMobileFiltersOpen && ( + setSortBy(value)} + options={cardSortSelectOptions} + /> + )} + {filterable && !setMobileFiltersOpen && ( @@ -659,6 +702,40 @@ export function ListView({ )}
+ + {/* Mobile filter drawer */} + {filterable && setMobileFiltersOpen && ( + setMobileFiltersOpen(false)} + open={mobileFiltersOpen} + width={300} + > + + + {viewMode === 'card' && cardSortSelectOptions && ( + setSortBy(value)} + options={cardSortSelectOptions} + /> + )} + filterControlsRef.current?.clearFilters()} + > + {t('Clear all')} + + + + )} ); } diff --git a/superset-frontend/src/components/ListView/utils.ts b/superset-frontend/src/components/ListView/utils.ts index 12df0e28bb9..5c25c941eac 100644 --- a/superset-frontend/src/components/ListView/utils.ts +++ b/superset-frontend/src/components/ListView/utils.ts @@ -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( - (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' })), diff --git a/superset-frontend/src/components/MobileRouteGuard/MobileRouteGuard.test.tsx b/superset-frontend/src/components/MobileRouteGuard/MobileRouteGuard.test.tsx new file mode 100644 index 00000000000..36ce02b501c --- /dev/null +++ b/superset-frontend/src/components/MobileRouteGuard/MobileRouteGuard.test.tsx @@ -0,0 +1,64 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { MemoryRouter } from 'react-router-dom'; +import { render, screen } from 'spec/helpers/testing-library'; +import { useIsMobile } from 'src/hooks/useIsMobile'; +import MobileRouteGuard from '.'; + +jest.mock('src/hooks/useIsMobile', () => ({ + useIsMobile: jest.fn(), + isMobileConsumptionEnabled: jest.fn().mockReturnValue(true), +})); + +const mockedUseIsMobile = useIsMobile as jest.MockedFunction< + typeof useIsMobile +>; + +const renderGuard = (mobileSupported?: boolean) => + render( + + +
Content
+
+
, + ); + +beforeEach(() => { + mockedUseIsMobile.mockReturnValue(false); +}); + +test('renders children on desktop regardless of mobileSupported', () => { + renderGuard(undefined); + expect(screen.getByTestId('guarded-content')).toBeInTheDocument(); +}); + +test('renders children on mobile when the route is mobileSupported', () => { + mockedUseIsMobile.mockReturnValue(true); + renderGuard(true); + expect(screen.getByTestId('guarded-content')).toBeInTheDocument(); +}); + +test('shows the unsupported screen on mobile for unsupported routes', () => { + mockedUseIsMobile.mockReturnValue(true); + renderGuard(undefined); + expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument(); + expect( + screen.getByText("This view isn't available on mobile"), + ).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/MobileRouteGuard/index.tsx b/superset-frontend/src/components/MobileRouteGuard/index.tsx new file mode 100644 index 00000000000..26b9a7a0ed3 --- /dev/null +++ b/superset-frontend/src/components/MobileRouteGuard/index.tsx @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ReactNode } from 'react'; +import { useIsMobile } from 'src/hooks/useIsMobile'; +import MobileUnsupported from 'src/pages/MobileUnsupported'; + +interface MobileRouteGuardProps { + children: ReactNode; + /** + * Whether the wrapped route is part of the mobile consumption + * experience. Set via the `mobileSupported` flag on the route + * definition in `src/views/routes.tsx`. + */ + mobileSupported?: boolean; +} + +/** + * Wraps route content and shows the MobileUnsupported page when a + * non-mobile-friendly route is accessed on a small screen with + * MOBILE_CONSUMPTION_MODE enabled. Growing the viewport past the + * breakpoint unblocks the route automatically. + */ +function MobileRouteGuard({ + children, + mobileSupported, +}: MobileRouteGuardProps) { + const isMobile = useIsMobile(); + + if (!isMobile || mobileSupported) { + return <>{children}; + } + + return ; +} + +export default MobileRouteGuard; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx index 68127e583ea..b2f1203318a 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx @@ -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(, { + 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(, { + 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(, { + useRedux: true, + store: storeWithState({ + ...mockState, + dashboardLayout: undoableDashboardLayout, + }), + useDnd: true, + useTheme: true, + useRouter: true, + }); + + // Header container should be present + expect(queryByTestId('dashboard-header-container')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index 8b4c9f9ad9f..6f33a446753 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -23,7 +23,7 @@ import { t } from '@apache-superset/core/translation'; import { addAlpha, JsonObject, useElementOnScreen } from '@superset-ui/core'; import { css, styled, useTheme } from '@apache-superset/core/theme'; import { useDispatch, useSelector } from 'react-redux'; -import { EmptyState, Loading } from '@superset-ui/core/components'; +import { Drawer, EmptyState, Loading } from '@superset-ui/core/components'; import { ErrorBoundary, BasicErrorAlert } from 'src/components'; import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane'; import DashboardHeader from 'src/dashboard/components/Header'; @@ -59,6 +59,7 @@ import { } from 'src/dashboard/util/constants'; import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar'; import { useUiConfig } from 'src/components/UiConfigContext'; +import { isMobileConsumptionEnabled, useIsMobile } from 'src/hooks/useIsMobile'; import ResizableSidebar from 'src/components/ResizableSidebar'; import { BUILDER_SIDEPANEL_WIDTH, @@ -101,6 +102,19 @@ const StyledHeader = styled.div<{ filterBarWidth: number }>` z-index: 99; max-width: calc(100vw - ${filterBarWidth}px); + /* Mobile consumption mode: let the dashboard title scroll away and keep + only the tab bar sticky. A pinned title would sit underneath the + higher-z sticky tabs, leaving its bottom edge (kebab button) peeking + out below the tab bar. */ + ${ + isMobileConsumptionEnabled() && + css` + @media (max-width: ${theme.screenSMMax}px) { + position: relative; + } + ` + } + .empty-droptarget { min-height: ${theme.sizeUnit * 4}px; } @@ -372,6 +386,8 @@ const DashboardBuilder = () => { const dispatch = useDispatch(); const uiConfig = useUiConfig(); const theme = useTheme(); + const isNotMobile = !useIsMobile(); + const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); const dashboardId = useSelector( ({ dashboardInfo }) => `${dashboardInfo.id}`, @@ -462,13 +478,14 @@ const DashboardBuilder = () => { dashboardFiltersOpen, toggleDashboardFiltersOpen, nativeFiltersEnabled, + hasFilters, } = useNativeFilters(); const [containerRef, isSticky] = useElementOnScreen( ELEMENT_ON_SCREEN_OPTIONS, ); - const showFilterBar = !editMode && nativeFiltersEnabled; + const showFilterBar = isNotMobile && !editMode && nativeFiltersEnabled; const offset = FILTER_BAR_HEADER_HEIGHT + @@ -480,6 +497,7 @@ const DashboardBuilder = () => { const draggableStyle = useMemo( () => ({ marginLeft: + !isNotMobile || dashboardFiltersOpen || editMode || !nativeFiltersEnabled || @@ -488,6 +506,7 @@ const DashboardBuilder = () => { : -32, }), [ + isNotMobile, dashboardFiltersOpen, editMode, filterBarOrientation, @@ -519,7 +538,15 @@ const DashboardBuilder = () => { const headerContent = useMemo( () => ( <> - {!hideDashboardHeader && } + {!hideDashboardHeader && ( + setMobileFiltersOpen(true) + : undefined + } + /> + )} {/* Report mode is a one-shot screenshot render (reports, thumbnails), so it must never start a refresh timer that could re-fetch charts mid-capture. */} @@ -533,7 +560,15 @@ const DashboardBuilder = () => { )} ), - [hideDashboardHeader, showFilterBar, filterBarOrientation, isReport], + [ + hideDashboardHeader, + isNotMobile, + nativeFiltersEnabled, + hasFilters, + showFilterBar, + filterBarOrientation, + isReport, + ], ); const renderDraggableContent = useCallback( @@ -577,6 +612,8 @@ const DashboardBuilder = () => { topLevelTabs, uiConfig.hideTab, uiConfig.hideNav, + isNotMobile, + theme, ], ); @@ -739,6 +776,36 @@ const DashboardBuilder = () => { `} /> )} + {/* Mobile filters drawer */} + {!isNotMobile && nativeFiltersEnabled && ( + setMobileFiltersOpen(false)} + open={mobileFiltersOpen} + width="85vw" + styles={{ + body: { + padding: 0, + display: 'flex', + flexDirection: 'column', + }, + }} + > + {}, + width: 300, + height: '100%', + offset: 0, + mobileMode: true, + }} + hidden={false} + /> + + )} ); }; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx index 8fd07069aff..d6522c7a61b 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx @@ -25,6 +25,7 @@ import { useSelector } from 'react-redux'; import { useDragDropManager } from 'react-dnd'; import classNames from 'classnames'; import { debounce } from 'lodash-es'; +import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile'; const StyledDiv = styled.div` ${({ theme }) => css` @@ -110,6 +111,22 @@ const StyledDiv = styled.div` i.warning { color: ${theme.colorWarning}; } + + /* Mobile consumption mode: show the full chart title without + truncation (controls and links are render-gated in SliceHeader) */ + ${ + isMobileConsumptionEnabled() + ? `@media (max-width: ${theme.screenSMMax}px) { + [data-test='slice-header'] .header-title { + -webkit-line-clamp: unset; + display: block; + white-space: normal; + overflow: visible; + text-overflow: unset; + } + }` + : '' + } `} `; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts index a6de9e390c8..67ed1a81c18 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts @@ -121,5 +121,6 @@ export const useNativeFilters = () => { dashboardFiltersOpen, toggleDashboardFiltersOpen, nativeFiltersEnabled, + hasFilters: filterValues.length > 0, }; }; diff --git a/superset-frontend/src/dashboard/components/Header/Header.test.tsx b/superset-frontend/src/dashboard/components/Header/Header.test.tsx index 98397ec6075..48830afbaba 100644 --- a/superset-frontend/src/dashboard/components/Header/Header.test.tsx +++ b/superset-frontend/src/dashboard/components/Header/Header.test.tsx @@ -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(), })); diff --git a/superset-frontend/src/dashboard/components/Header/index.tsx b/superset-frontend/src/dashboard/components/Header/index.tsx index ca3b87501f9..70d91dab581 100644 --- a/superset-frontend/src/dashboard/components/Header/index.tsx +++ b/superset-frontend/src/dashboard/components/Header/index.tsx @@ -23,7 +23,12 @@ import { FeatureFlag, getExtensionsRegistry, } from '@superset-ui/core'; -import { styled, css, SupersetTheme } from '@apache-superset/core/theme'; +import { + styled, + css, + SupersetTheme, + useTheme, +} from '@apache-superset/core/theme'; import { t } from '@apache-superset/core/translation'; import { Global } from '@emotion/react'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; @@ -37,6 +42,7 @@ import { UnsavedChangesModal, } from '@superset-ui/core/components'; import { findPermission } from 'src/utils/findPermission'; +import { useIsMobile } from 'src/hooks/useIsMobile'; import { safeStringify } from 'src/utils/safeStringify'; import Subject from 'src/types/Subject'; import { DashboardLayout, RootState } from 'src/dashboard/types'; @@ -219,8 +225,14 @@ const discardChanges = () => { window.location.assign(url); }; -const Header = (): JSX.Element => { +interface HeaderComponentProps { + onOpenMobileFilters?: () => void; +} + +const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => { const dispatch = useDispatch(); + const theme = useTheme(); + const isMobile = useIsMobile(); const [didNotifyMaxUndoHistoryToast, setDidNotifyMaxUndoHistoryToast] = useState(false); const [emphasizeUndo, setEmphasizeUndo] = useState(false); @@ -631,7 +643,8 @@ const Header = (): JSX.Element => { const titlePanelAdditionalItems = useMemo( () => [ - !editMode && ( + // The kebab menu's "Refresh dashboard" item covers this on mobile + !editMode && !isMobile && ( ), !editMode && ( @@ -640,7 +653,7 @@ const Header = (): JSX.Element => { onTogglePause={handlePauseToggle} /> ), - !editMode && ( + !editMode && !isMobile && ( { userCanSave={userCanSaveAs} /> ), - !editMode && !isEmbedded && metadataBar, + !editMode && !isEmbedded && !isMobile && metadataBar, ], [ boundActionCreators.savePublished, dashboardInfo.id, editMode, + isMobile, metadataBar, isEmbedded, isPublished, @@ -751,7 +765,7 @@ const Header = (): JSX.Element => { ) : (
{NavExtension && } - {userCanEdit && !isEmbedded && ( + {userCanEdit && !isEmbedded && !isMobile && ( + ) + } titlePanelAdditionalItems={titlePanelAdditionalItems} rightPanelAdditionalItems={rightPanelAdditionalItems} menuDropdownProps={{ @@ -841,7 +875,7 @@ const Header = (): JSX.Element => { onOpenChange: setIsDropdownVisible, }} additionalActionsMenu={menu} - showFaveStar={Boolean(user?.userId && dashboardInfo?.id)} + showFaveStar={!!(user?.userId && dashboardInfo?.id && !isMobile)} showTitlePanelItems /> {showingPropertiesModal && ( diff --git a/superset-frontend/src/dashboard/components/Header/types.ts b/superset-frontend/src/dashboard/components/Header/types.ts index d5fee210d37..c86d0535b3d 100644 --- a/superset-frontend/src/dashboard/components/Header/types.ts +++ b/superset-frontend/src/dashboard/components/Header/types.ts @@ -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; diff --git a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx index 8a1c09249f2..6bdcbe7f699 100644 --- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx +++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx @@ -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, diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index 615e6b4e694..ccf490f087a 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -43,6 +43,7 @@ import { isEmbedded } from 'src/dashboard/util/isEmbedded'; import { Tooltip, EditableTitle, Icons } from '@superset-ui/core/components'; import { useSelector } from 'react-redux'; import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls'; +import { useIsMobile } from 'src/hooks/useIsMobile'; import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types'; import MemoizedFiltersBadge from 'src/dashboard/components/FiltersBadge'; import MemoizedCustomizationsBadge from 'src/dashboard/components/CustomizationsBadge'; @@ -229,7 +230,9 @@ const SliceHeader = forwardRef( 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( } /> )} - {!uiConfig.hideChartControls && ( + {!uiConfig.hideChartControls && !isMobile && ( { const theme = useTheme(); + const isMobile = useIsMobile(); const fullSizeStyle = css` && { position: fixed !important; @@ -167,6 +174,14 @@ const ChartHolder = ({ }, [outlinedComponentId]); const widthMultiple = useMemo(() => { + // Mobile consumption mode stacks charts vertically at full width, so + // report the full column count. This keeps the pixel width handed to the + // chart plugin (and to ResizableContainer's inline size) in sync with the + // stacked layout instead of the desktop grid fraction. + if (isMobile && !editMode) { + return GRID_COLUMN_COUNT; + } + const columnParentWidth = getComponentById( parentComponent.parents?.find(parent => parent.startsWith(COLUMN_TYPE)), )?.meta?.width; @@ -182,11 +197,29 @@ const ChartHolder = ({ }, [ component, getComponentById, + isMobile, + editMode, parentComponent.meta.width, parentComponent.parents, parentComponent.type, ]); + // Grid units of height for this chart. In mobile consumption mode the + // authored desktop height is capped to the viewport (minus app chrome) so + // tall charts don't dominate the single-column stacked layout. Used for + // both the ResizableContainer shell and the height handed to the plugin, + // so the two can't disagree. + const heightMultiple = useMemo(() => { + const authoredHeight = component.meta.height ?? GRID_MIN_ROW_UNITS; + if (isMobile && !editMode) { + const maxUnits = Math.floor( + (window.innerHeight - MOBILE_CHROME_HEIGHT) / GRID_BASE_UNIT, + ); + return Math.max(GRID_MIN_ROW_UNITS, Math.min(authoredHeight, maxUnits)); + } + return authoredHeight; + }, [component.meta.height, isMobile, editMode]); + const { chartWidth, chartHeight } = useMemo(() => { let width = 0; let height = 0; @@ -200,16 +233,14 @@ const ChartHolder = ({ (widthMultiple - 1) * GRID_GUTTER_SIZE - CHART_MARGIN, ); - height = Math.floor( - (component.meta.height ?? 0) * GRID_BASE_UNIT - CHART_MARGIN, - ); + height = Math.floor(heightMultiple * GRID_BASE_UNIT - CHART_MARGIN); } return { chartWidth: width, chartHeight: height, }; - }, [columnWidth, component, isFullSize, widthMultiple]); + }, [columnWidth, heightMultiple, isFullSize, widthMultiple]); const handleDeleteComponent = useCallback(() => { deleteComponent(id, parentId); @@ -250,7 +281,7 @@ const ChartHolder = ({ widthStep={columnWidth} widthMultiple={widthMultiple} heightStep={GRID_BASE_UNIT} - heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS} + heightMultiple={heightMultiple} minWidthMultiple={GRID_MIN_COLUMN_COUNT} minHeightMultiple={GRID_MIN_ROW_UNITS} maxWidthMultiple={availableColumnCount + widthMultiple} @@ -342,12 +373,12 @@ const ChartHolder = ({ ), [ component.id, - component.meta.height, component.meta.chartId, component.meta.sliceNameOverride, component.meta.sliceName, parentComponent.type, columnWidth, + heightMultiple, widthMultiple, availableColumnCount, onResizeStart, diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx index c5ec0c497f5..0e558ee1da4 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx @@ -37,6 +37,7 @@ import { Droppable, } from 'src/dashboard/components/dnd/DragDroppable'; import DragHandle from 'src/dashboard/components/dnd/DragHandle'; +import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile'; import DashboardComponent from 'src/dashboard/containers/DashboardComponent'; import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton'; import HoverMenu from 'src/dashboard/components/menu/HoverMenu'; @@ -120,6 +121,27 @@ const GridRow = styled.div<{ editMode: boolean }>` &.grid-row--empty { min-height: ${theme.sizeUnit * 25}px; } + + ${ + isMobileConsumptionEnabled() && + css` + @media (max-width: ${theme.screenSMMax}px) { + flex-direction: column; + + & > :not(.hover-menu) { + width: 100% !important; + margin-right: 0 !important; + } + + /* Stacked children get the same vertical gutter GridContent puts + between rows and Column puts between its children, so spacing + stays uniform across the whole stacked layout */ + & > :not(.hover-menu):not(:last-child) { + margin-bottom: ${theme.sizeUnit * 4}px; + } + } + ` + } `} `; diff --git a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx index d9e34e8ec23..b62e889ef52 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx @@ -25,7 +25,8 @@ import { useRef, useState, } from 'react'; -import { styled } from '@apache-superset/core/theme'; +import { css, styled } from '@apache-superset/core/theme'; +import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile'; import { LineEditableTabs, TabsProps as AntdTabsProps, @@ -81,6 +82,58 @@ const StyledTabsContainer = styled.div<{ isDragging?: boolean }>` display: none !important; } `} + + /* Sticky tabs on mobile (consumption mode) */ + ${({ theme }) => + isMobileConsumptionEnabled() && + css` + @media (max-width: ${theme.screenSMMax}px) { + .ant-tabs-nav { + position: sticky; + top: 0; + z-index: 100; + background-color: ${theme.colorBgContainer}; + /* breathing room between the tab bar and the first card; padding + (not margin) so the gap is part of the opaque sticky bar */ + padding-bottom: ${theme.sizeUnit * 2}px; + } + + /* Scrollability affordance: fade the clipped edge with a + theme-colored gradient. antd toggles the ping classes when tabs + overflow on that side; restyle its shadow elements as gradients, + which read much better than the default shadows on dark themes. */ + .ant-tabs-nav-wrap:before, + .ant-tabs-nav-wrap:after { + width: ${theme.sizeUnit * 10}px; + box-shadow: none !important; + pointer-events: none; + } + .ant-tabs-nav-wrap-ping-right:after { + background: linear-gradient( + to right, + transparent, + ${theme.colorBgContainer} + ); + opacity: 1; + } + .ant-tabs-nav-wrap-ping-left:before { + background: linear-gradient( + to left, + transparent, + ${theme.colorBgContainer} + ); + opacity: 1; + } + + /* Swipeable tab bar instead of the overflow dropdown: the "more" + menu is a poor touch target and duplicates half-clipped tabs. + antd's tab nav supports touch-drag scrolling natively and shows + edge shadows (ping classes) when tabs overflow. */ + .ant-tabs-nav-operations { + display: none !important; + } + } + `} `; export interface TabItem { diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx index 17d457f81d5..2c86426269a 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx @@ -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 = ({ onPendingCustomizationDataMaskChange, toggleFiltersBar, width, + mobileMode, }) => { const theme = useTheme(); const [isScrolling, setIsScrolling] = useState(false); @@ -260,33 +261,56 @@ const VerticalFilterBar: FC = ({ {...getFilterBarTestId()} className={cx({ open: filtersOpen })} width={width} + css={ + mobileMode && + css` + width: 100%; + &.open { + width: 100%; + } + ` + } > - + + + + )} + - - - - -
+ {!mobileMode &&
} {!isInitialized ? (
= ({ } toggleFiltersBar={verticalConfig.toggleFiltersBar} width={verticalConfig.width} + mobileMode={verticalConfig.mobileMode} clearAllTriggers={clearAllTriggers} onClearAllComplete={handleClearAllComplete} /> diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/types.ts index 5354d7b7e45..8bb2cc0ddc5 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/types.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/types.ts @@ -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 { diff --git a/superset-frontend/src/dashboard/components/resizable/ResizableContainer.tsx b/superset-frontend/src/dashboard/components/resizable/ResizableContainer.tsx index a0cd0c3f95e..a84631b61c5 100644 --- a/superset-frontend/src/dashboard/components/resizable/ResizableContainer.tsx +++ b/superset-frontend/src/dashboard/components/resizable/ResizableContainer.tsx @@ -35,6 +35,7 @@ import { BottomResizeHandle, BottomRightResizeHandle, } from './ResizableHandle'; +import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile'; import resizableConfig from '../../util/resizableConfig'; import { GRID_BASE_UNIT, @@ -178,6 +179,21 @@ const StyledResizable = styled(Resizable)` & .resizable-container-handle--bottom { bottom: 0 !important; } + + /* Mobile consumption mode stacks all grid components full-width. + !important is required to beat re-resizable's inline width, which is + sized for the desktop grid (Markdown, Column, and other non-chart + components don't get the JS-level mobile width that ChartHolder + computes). */ + ${({ theme }) => + isMobileConsumptionEnabled() && + css` + @media (max-width: ${theme.screenSMMax}px) { + width: 100% !important; + max-width: 100% !important; + min-width: 100% !important; + } + `} `; export default function ResizableContainer({ diff --git a/superset-frontend/src/features/dashboards/DashboardCard.tsx b/superset-frontend/src/features/dashboards/DashboardCard.tsx index b40542cac40..92f78e0d17e 100644 --- a/superset-frontend/src/features/dashboards/DashboardCard.tsx +++ b/superset-frontend/src/features/dashboards/DashboardCard.tsx @@ -38,6 +38,7 @@ import { SubjectPile } from 'src/features/subjects/SubjectPile'; import { KebabMenuButton } from 'src/components'; import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils'; import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { useIsMobile } from 'src/hooks/useIsMobile'; interface DashboardCardProps { isChart?: boolean; @@ -67,6 +68,7 @@ function DashboardCard({ onDelete, }: DashboardCardProps) { const userId = user?.userId; + const isMobile = useIsMobile(); const history = useHistory(); const canEdit = hasPerm('can_write'); @@ -212,10 +214,12 @@ function DashboardCard({ isStarred={favoriteStatus} /> )} - + {!isMobile && ( + + )} } /> diff --git a/superset-frontend/src/features/home/ActivityTable.tsx b/superset-frontend/src/features/home/ActivityTable.tsx index 7cce8a0bc32..4c276ba59e8 100644 --- a/superset-frontend/src/features/home/ActivityTable.tsx +++ b/superset-frontend/src/features/home/ActivityTable.tsx @@ -32,6 +32,7 @@ import { } from 'src/views/CRUD/utils'; import { Chart } from 'src/types/Chart'; import { Icons } from '@superset-ui/core/components/Icons'; +import { useIsMobile } from 'src/hooks/useIsMobile'; import SubMenu from './SubMenu'; import EmptyState from './EmptyState'; import { WelcomeTable, RecentActivity } from './types'; @@ -70,6 +71,12 @@ const Styles = styled.div` const UNTITLED = t('[Untitled]'); const UNKNOWN_TIME = t('Unknown'); +// Dashboards are the only activity entities viewable in the mobile +// consumption experience; charts and saved queries link to unsupported views +const isDashboardEntity = (entity: ActivityObject) => + 'dashboard_title' in entity || + ('item_type' in entity && entity.item_type === 'dashboard'); + const getEntityTitle = (entity: ActivityObject) => { if ('dashboard_title' in entity) return entity.dashboard_title || UNTITLED; if ('slice_name' in entity) return entity.slice_name || UNTITLED; @@ -121,6 +128,7 @@ export default function ActivityTable({ }: ActivityProps) { const [editedCards, setEditedCards] = useState(); const [isFetchingEditedCards, setIsFetchingEditedCards] = useState(false); + const isMobile = useIsMobile(); useEffect(() => { let isMounted = true; @@ -174,12 +182,16 @@ export default function ActivityTable({ }, }); } - const renderActivity = () => { - const activities = - (activeChild === TableTab.Edited - ? editedCards - : activityData[activeChild as keyof ActivityData]) ?? []; - return activities.map((entity: ActivityObject) => { + const rawActivities = + (activeChild === TableTab.Edited + ? editedCards + : activityData[activeChild as keyof ActivityData]) ?? []; + const activities = isMobile + ? rawActivities.filter(isDashboardEntity) + : rawActivities; + + const renderActivity = () => + activities.map((entity: ActivityObject) => { const url = getEntityUrl(entity); const lastActionOn = getEntityLastActionOn(entity); return ( @@ -197,7 +209,6 @@ export default function ActivityTable({ ); }); - }; if ((isFetchingEditedCards && !editedCards) || isFetchingActivityData) { return ; @@ -209,8 +220,7 @@ export default function ActivityTable({ tabs={tabs} backgroundColor="transparent" /> - {Number(activityData[activeChild as keyof ActivityData]?.length) > 0 || - (activeChild === TableTab.Edited && editedCards?.length) ? ( + {activities.length > 0 ? ( {renderActivity()} diff --git a/superset-frontend/src/features/home/EmptyState.tsx b/superset-frontend/src/features/home/EmptyState.tsx index c3802b74798..28eec15ad17 100644 --- a/superset-frontend/src/features/home/EmptyState.tsx +++ b/superset-frontend/src/features/home/EmptyState.tsx @@ -24,6 +24,7 @@ import { TableTab } from 'src/views/CRUD/types'; import { t } from '@apache-superset/core/translation'; import { styled } from '@apache-superset/core/theme'; import { navigateTo } from 'src/utils/navigationUtils'; +import { useIsMobile } from 'src/hooks/useIsMobile'; import { WelcomeTable } from './types'; const EmptyContainer = styled.div` @@ -76,12 +77,19 @@ export interface EmptyStateProps { } export default function EmptyState({ tableName, tab }: EmptyStateProps) { + const isMobile = useIsMobile(); const getActionButton = () => { if (tableName === WelcomeTable.Recents) { return null; } const isFavorite = tab === TableTab.Favorite; + + // Creation flows aren't part of the mobile consumption experience; + // only offer the "See all ..." navigation there + if (isMobile && !isFavorite) { + return null; + } const buttonText = isFavorite ? LABELS.viewAll[tableName] : LABELS.create[tableName]; @@ -111,7 +119,7 @@ export default function EmptyState({ tableName, tab }: EmptyStateProps) { {getActionButton()} diff --git a/superset-frontend/src/features/home/Menu.tsx b/superset-frontend/src/features/home/Menu.tsx index 0cebd8d16fd..28dc810bcce 100644 --- a/superset-frontend/src/features/home/Menu.tsx +++ b/superset-frontend/src/features/home/Menu.tsx @@ -29,6 +29,7 @@ import { NavLink, useLocation } from 'react-router-dom'; import { Icons } from '@superset-ui/core/components/Icons'; import { Typography } from '@superset-ui/core/components/Typography'; import { useUiConfig } from 'src/components/UiConfigContext'; +import { useIsMobile } from 'src/hooks/useIsMobile'; import { URL_PARAMS } from 'src/constants'; import { MenuObjectChildProps, @@ -199,6 +200,7 @@ export function Menu({ isFrontendRoute = () => false, }: MenuProps) { const screens = useBreakpoint(); + const isMobile = useIsMobile(); const uiConfig = useUiConfig(); const theme = useTheme(); // screens.md is undefined on the first render before breakpoints are measured; @@ -405,7 +407,18 @@ export function Menu({ aria-label={t('Main navigation')} > - + {/* Mobile: left placeholder for future icon */} + {isMobile && } + {!brand.hide_logo && ( {brand.text} )} - { - 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 && ( + { + 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); + })} + /> + )} - + diff --git a/superset-frontend/src/features/home/RightMenu.test.tsx b/superset-frontend/src/features/home/RightMenu.test.tsx index d57f676cd17..1adaabf9d63 100644 --- a/superset-frontend/src/features/home/RightMenu.test.tsx +++ b/superset-frontend/src/features/home/RightMenu.test.tsx @@ -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 = () => ; DatabaseModal.displayName = 'DatabaseModal'; diff --git a/superset-frontend/src/features/home/RightMenu.tsx b/superset-frontend/src/features/home/RightMenu.tsx index 9e69b469e80..5ea02e14e13 100644 --- a/superset-frontend/src/features/home/RightMenu.tsx +++ b/superset-frontend/src/features/home/RightMenu.tsx @@ -51,9 +51,16 @@ import { Icons, Typography, TelemetryPixel, + Drawer, + Button, } from '@superset-ui/core/components'; import type { ItemType, MenuItem } from '@superset-ui/core/components/Menu'; -import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils'; +import { + ensureAppRoot, + navigateTo, + stripAppRoot, +} from 'src/utils/navigationUtils'; +import { useIsMobile } from 'src/hooks/useIsMobile'; import { isEmbedded } from 'src/dashboard/util/isEmbedded'; import { findPermission } from 'src/utils/findPermission'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; @@ -119,6 +126,7 @@ const RightMenu = ({ navbarRight, isFrontendRoute, environmentTag, + menu, setQuery, }: RightMenuProps & { setQuery: ({ @@ -130,6 +138,8 @@ const RightMenu = ({ }) => void; }) => { const theme = useTheme(); + const isMobile = useIsMobile(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const user = useSelector( state => state.user, ); @@ -644,6 +654,64 @@ const RightMenu = ({ handleLogout, ]); + // Build mobile menu items - consumption only (no create/admin actions) + const mobileMenuItems = useMemo(() => { + const items: MenuItem[] = []; + + // Add Dashboards link at top (from main menu) + // Match on the FAB-internal `name`, which is stable across locales + // (`label` is translated and would break in non-English deployments) + const dashboardsMenu = menu?.find(item => item.name === 'Dashboards'); + if (dashboardsMenu) { + const dashboardUrl = dashboardsMenu.url || '/dashboard/list/'; + items.push({ + key: 'dashboards', + label: isFrontendRoute(dashboardUrl) ? ( + {t('Dashboards')} + ) : ( + + {t('Dashboards')} + + ), + icon: , + }); + } + + // 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 ( {canDatabase && ( @@ -704,48 +772,98 @@ const RightMenu = ({ ); })()} - + + setMobileMenuOpen(false)} + open={mobileMenuOpen} + width={280} + styles={{ + header: { display: 'none' }, + body: { padding: 0 }, + }} + > + { + handleMenuSelection(info); + // The reused desktop items navigate via anchors that only + // span their label text, but the drawer's tap target is the + // full menu row — navigate explicitly so row taps work. + if (info.key === 'info' && navbarRight.user_info_url) { + navigateTo(navbarRight.user_info_url); + return; + } + if (info.key === 'logout' && navbarRight.user_logout_url) { + navigateTo(navbarRight.user_logout_url); + return; + } + setMobileMenuOpen(false); + }} + items={mobileMenuItems} + css={css` + border-inline-end: none !important; + `} + /> + + + )} + {/* Desktop: horizontal menu */} + {!isMobile && ( + + `} + selectable={false} + mode="horizontal" + onClick={handleMenuSelection} + onOpenChange={onMenuOpen} + disabledOverflow + items={menuItems} + /> + )} {navbarRight.documentation_url && ( <> { userEvent.click(testButton); expect(mockFunc).toHaveBeenCalled(); }); + +// Mobile support tests +test('should render leftIcon when provided', async () => { + setup({ + leftIcon: ( + + ), + }); + expect(await screen.findByTestId('left-icon-button')).toBeInTheDocument(); +}); + +test('should render rightIcon when provided', async () => { + setup({ + rightIcon: ( + + ), + }); + expect(await screen.findByTestId('right-icon-button')).toBeInTheDocument(); +}); + +test('should render both leftIcon and rightIcon together', async () => { + setup({ + leftIcon: Left, + rightIcon: Right, + }); + expect(await screen.findByTestId('mobile-left')).toBeInTheDocument(); + expect(await screen.findByTestId('mobile-right')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/features/home/SubMenu.tsx b/superset-frontend/src/features/home/SubMenu.tsx index d0c71fc02ec..661d49375f3 100644 --- a/superset-frontend/src/features/home/SubMenu.tsx +++ b/superset-frontend/src/features/home/SubMenu.tsx @@ -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; 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 = props => { @@ -186,8 +222,16 @@ const SubMenuComponent: FunctionComponent = 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 = props => { resize.cancel(); window.removeEventListener('resize', resize); }; - }, [props.buttons]); + }, [props.buttons, theme.screenSMMax]); return ( + {props.leftIcon} {props.name &&
{props.name}
} + {props.rightIcon} { + if (!enabled) { + return undefined; + } + const mediaQuery = window.matchMedia(`(max-width: ${maxWidth}px)`); + const update = () => setIsSmallScreen(mediaQuery.matches); + update(); + // Safari < 14 lacks addEventListener on MediaQueryList + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', update); + return () => mediaQuery.removeEventListener('change', update); + } + mediaQuery.addListener(update); + return () => mediaQuery.removeListener(update); + }, [enabled, maxWidth]); + + return enabled && isSmallScreen; +} diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx index b43607d4f68..70dc2e4365d 100644 --- a/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx +++ b/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx @@ -50,6 +50,15 @@ jest.mock('src/utils/getBootstrapData', () => mockUserSubjectsBootstrapData([1]), ); +// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering) +jest.mock('antd', () => ({ + ...jest.requireActual('antd'), + Grid: { + ...jest.requireActual('antd').Grid, + useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }), + }, +})); + const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction< typeof isFeatureEnabled >; diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index b2f475f4c89..a307836ed43 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -23,7 +23,7 @@ import { SupersetClient, handleKeyboardActivation, } from '@superset-ui/core'; -import { styled } from '@apache-superset/core/theme'; +import { styled, css, useTheme } from '@apache-superset/core/theme'; import { useSelector } from 'react-redux'; import { useState, useMemo, useCallback } from 'react'; import { Link } from 'react-router-dom'; @@ -39,7 +39,9 @@ import Subject from 'src/types/Subject'; import { SUBJECT_OPTION_FILTER_PROPS } from 'src/features/subjects/SubjectSelectLabel'; import { SubjectPile } from 'src/features/subjects/SubjectPile'; import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks'; +import { useIsMobile } from 'src/hooks/useIsMobile'; import { + Button, CertifiedBadge, ConfirmStatusChange, DeleteModal, @@ -174,6 +176,9 @@ const DASHBOARD_COLUMNS_TO_FETCH = [ function DashboardList(props: DashboardListProps) { const { addDangerToast, addSuccessToast, user } = props; + const isNotMobile = !useIsMobile(); + const theme = useTheme(); + const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); const { roles } = useSelector( state => state.user, ); @@ -833,7 +838,25 @@ function DashboardList(props: DashboardListProps) { } return ( <> - + setMobileFiltersOpen(true)} + aria-label={t('Search')} + css={css` + padding: 0; + margin-right: ${theme.sizeUnit * 2}px; + `} + > + + + ) : undefined + } + /> ); diff --git a/superset-frontend/src/pages/Home/Home.mobile.test.tsx b/superset-frontend/src/pages/Home/Home.mobile.test.tsx new file mode 100644 index 00000000000..268ec48648d --- /dev/null +++ b/superset-frontend/src/pages/Home/Home.mobile.test.tsx @@ -0,0 +1,179 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Mobile-specific tests for the Home/Welcome page. + * + * These tests verify that certain desktop-only sections are hidden + * on mobile viewports. + */ + +import fetchMock from 'fetch-mock'; +import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import Welcome from 'src/pages/Home'; + +import { mockMobileMatchMedia } from 'spec/helpers/mobileTestUtils'; + +// Enable only the mobile consumption mode flag so the mobile branch renders +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: jest.fn( + (flag: string) => flag === 'MOBILE_CONSUMPTION_MODE', + ), +})); + +// Simulate a mobile viewport for the useIsMobile hook +mockMobileMatchMedia(); + +// API mocks +const chartsEndpoint = 'glob:*/api/v1/chart/?*'; +const chartInfoEndpoint = 'glob:*/api/v1/chart/_info?*'; +const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status?*'; +const dashboardsEndpoint = 'glob:*/api/v1/dashboard/?*'; +const dashboardInfoEndpoint = 'glob:*/api/v1/dashboard/_info?*'; +const dashboardFavoriteStatusEndpoint = + 'glob:*/api/v1/dashboard/favorite_status/?*'; +const savedQueryEndpoint = 'glob:*/api/v1/saved_query/?*'; +const savedQueryInfoEndpoint = 'glob:*/api/v1/saved_query/_info?*'; +const recentActivityEndpoint = 'glob:*/api/v1/log/recent_activity/*'; + +fetchMock.get(chartsEndpoint, { + result: [ + { + slice_name: 'ChartyChart', + changed_on_utc: '24 Feb 2014 10:13:14', + url: '/fakeUrl/explore', + id: '4', + table: {}, + }, + ], +}); + +fetchMock.get(dashboardsEndpoint, { + result: [ + { + dashboard_title: 'Dashboard_Test', + changed_on_utc: '24 Feb 2014 10:13:14', + url: '/fakeUrl/dashboard', + id: '3', + }, + ], +}); + +fetchMock.get(savedQueryEndpoint, { + result: [], +}); + +fetchMock.get(recentActivityEndpoint, { + result: [ + { + action: 'dashboard', + item_title: "World Bank's Data", + item_type: 'dashboard', + item_url: '/superset/dashboard/world_health/', + time: 1741644942130.566, + time_delta_humanized: 'a day ago', + }, + ], +}); + +fetchMock.get(chartInfoEndpoint, { permissions: [] }); +fetchMock.get(chartFavoriteStatusEndpoint, { result: [] }); +fetchMock.get(dashboardInfoEndpoint, { permissions: [] }); +fetchMock.get(dashboardFavoriteStatusEndpoint, { result: [] }); +fetchMock.get(savedQueryInfoEndpoint, { permissions: [] }); + +const mockedProps = { + user: { + username: 'alpha', + firstName: 'alpha', + lastName: 'alpha', + createdOn: '2016-11-11T12:34:17', + userId: 5, + email: 'alpha@alpha.com', + isActive: true, + isAnonymous: false, + permissions: {}, + roles: { + sql_lab: [['can_read', 'SavedQuery']], + }, + }, +}; + +const renderWelcome = (props = mockedProps) => + waitFor(() => { + render(, { + 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(); +}); diff --git a/superset-frontend/src/pages/Home/Home.test.tsx b/superset-frontend/src/pages/Home/Home.test.tsx index 57350cb8522..8fd0e9aa4ae 100644 --- a/superset-frontend/src/pages/Home/Home.test.tsx +++ b/superset-frontend/src/pages/Home/Home.test.tsx @@ -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) => diff --git a/superset-frontend/src/pages/Home/index.tsx b/superset-frontend/src/pages/Home/index.tsx index 8a66a558cc5..7c88a627b4a 100644 --- a/superset-frontend/src/pages/Home/index.tsx +++ b/superset-frontend/src/pages/Home/index.tsx @@ -28,6 +28,7 @@ import { styled } from '@apache-superset/core/theme'; import rison from 'rison'; import { Collapse, ListViewCard } from '@superset-ui/core/components'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { useIsMobile } from 'src/hooks/useIsMobile'; import { reject } from 'lodash-es'; import { dangerouslyGetItemDoNotUse, @@ -147,6 +148,7 @@ export const LoadingCards = ({ cover }: LoadingProps) => ( ); function Welcome({ user, addDangerToast }: WelcomeProps) { + const isNotMobile = !useIsMobile(); const canReadSavedQueries = userHasPermission(user, 'SavedQuery', 'can_read'); const userid = user.userId; const id = userid!.toString(); // confident that user is not a guest user @@ -397,24 +399,29 @@ function Welcome({ user, addDangerToast }: WelcomeProps) { /> ), }, - { - key: 'charts', - label: t('Charts'), - children: - !chartData || isRecentActivityLoading ? ( - - ) : ( - - ), - }, - ...(canReadSavedQueries + // Hide Charts and Saved queries on mobile - consumption-only mode + ...(isNotMobile + ? [ + { + key: 'charts', + label: t('Charts'), + children: + !chartData || isRecentActivityLoading ? ( + + ) : ( + + ), + }, + ] + : []), + ...(isNotMobile && canReadSavedQueries ? [ { key: 'saved-queries', diff --git a/superset-frontend/src/pages/MobileUnsupported/MobileUnsupported.test.tsx b/superset-frontend/src/pages/MobileUnsupported/MobileUnsupported.test.tsx new file mode 100644 index 00000000000..13023410ffb --- /dev/null +++ b/superset-frontend/src/pages/MobileUnsupported/MobileUnsupported.test.tsx @@ -0,0 +1,95 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { render, screen } from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import MobileUnsupported from './index'; + +// Mock useHistory +const mockPush = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockPush, + }), +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const renderComponent = () => + render( + + + , + ); + +test('renders the page title', () => { + renderComponent(); + expect( + screen.getByText("This view isn't available on mobile"), + ).toBeInTheDocument(); +}); + +test('renders the description text', () => { + renderComponent(); + expect( + screen.getByText( + 'Some features require a larger screen. Try viewing dashboards for the best mobile experience.', + ), + ).toBeInTheDocument(); +}); + +test('renders the View Dashboards button', () => { + renderComponent(); + expect( + screen.getByRole('button', { name: 'View Dashboards' }), + ).toBeInTheDocument(); +}); + +test('renders the Go to Welcome Page button', () => { + renderComponent(); + expect( + screen.getByRole('button', { name: 'Go to Welcome Page' }), + ).toBeInTheDocument(); +}); + +test('does not render a Continue anyway bypass', () => { + renderComponent(); + expect(screen.queryByText(/Continue anyway/)).not.toBeInTheDocument(); +}); + +test('View Dashboards button navigates to dashboard list', async () => { + renderComponent(); + + const button = screen.getByRole('button', { name: 'View Dashboards' }); + await userEvent.click(button); + + expect(mockPush).toHaveBeenCalledWith('/dashboard/list/'); +}); + +test('Go to Welcome Page button navigates to welcome page', async () => { + renderComponent(); + + const button = screen.getByRole('button', { name: 'Go to Welcome Page' }); + await userEvent.click(button); + + expect(mockPush).toHaveBeenCalledWith('/welcome/'); +}); diff --git a/superset-frontend/src/pages/MobileUnsupported/index.tsx b/superset-frontend/src/pages/MobileUnsupported/index.tsx new file mode 100644 index 00000000000..bbff5bc636c --- /dev/null +++ b/superset-frontend/src/pages/MobileUnsupported/index.tsx @@ -0,0 +1,143 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; +import { t } from '@apache-superset/core/translation'; +import { css, useTheme } from '@apache-superset/core/theme'; +import { Button } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; + +/** + * A mobile-friendly page shown when users try to access + * features that aren't supported on mobile devices. Growing the window + * past the mobile breakpoint unblocks the route automatically (useIsMobile + * subscribes to the breakpoint), so no manual bypass is offered. + */ +function MobileUnsupported() { + const theme = useTheme(); + const history = useHistory(); + + const handleViewDashboards = useCallback(() => { + history.push('/dashboard/list/'); + }, [history]); + + const handleGoHome = useCallback(() => { + history.push('/welcome/'); + }, [history]); + + return ( +
+ {/* Icon */} +
+ +
+ + {/* Title */} +

+ {t("This view isn't available on mobile")} +

+ + {/* Description */} +

+ {t( + 'Some features require a larger screen. Try viewing dashboards for the best mobile experience.', + )} +

+ + {/* Primary action */} +
+ +
+ + {/* Secondary action */} +
+ +
+
+ ); +} + +export default MobileUnsupported; diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 1fa904fddb4..6f50549c3dd 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -35,6 +35,7 @@ import { css, useTheme } from '@apache-superset/core/theme'; import { Flex, Layout, Loading } from '@superset-ui/core/components'; import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact'; import { ErrorBoundary } from 'src/components'; +import MobileRouteGuard from 'src/components/MobileRouteGuard'; import MenuWrapper from 'src/features/home/Menu'; import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData'; import ToastContainer from 'src/components/MessageToasts/ToastContainer'; @@ -97,19 +98,29 @@ const RouteSwitch = () => { const theme = useTheme(); return ( - {routes.map(({ path, Component, props = {}, Fallback = Loading }) => ( - - }> - - - - - - ))} + {routes.map( + ({ + path, + Component, + props = {}, + Fallback = Loading, + mobileSupported, + }) => ( + + }> + + + + + + + + ), + )} ); diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index baff54d29a9..6081d853852 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -26,6 +26,7 @@ import { lruCache, } from '@superset-ui/core'; import { styled } from '@apache-superset/core/theme'; +import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile'; import Chart from 'src/types/Chart'; import { intersection } from 'lodash-es'; import rison from 'rison'; @@ -457,6 +458,17 @@ export const CardContainer = styled.div<{ ? `${theme.sizeUnit * 8 + 3}px ${theme.sizeUnit * 20}px` : `${theme.sizeUnit * 8 + 1}px ${theme.sizeUnit * 20}px` }; + + /* Full-width cards on mobile (consumption mode) */ + ${ + isMobileConsumptionEnabled() + ? `@media (max-width: ${theme.screenSMMax}px) { + grid-template-columns: 1fr; + padding-left: ${theme.sizeUnit * 4}px; + padding-right: ${theme.sizeUnit * 4}px; + }` + : '' + } `} `; diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 0e1901256fb..c2a690ddd1f 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -194,18 +194,36 @@ type Routes = { Component: ComponentType; Fallback?: ComponentType; props?: ComponentProps; + /** + * 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 }, ]; diff --git a/superset/config.py b/superset/config.py index 61fddd53e4e..338bff4308c 100644 --- a/superset/config.py +++ b/superset/config.py @@ -708,6 +708,11 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = { # Enable Matrixify feature for matrix-style chart layouts # @lifecycle: development "MATRIXIFY": False, + # Serve a consumption-only mobile experience (dashboards, dashboard list, + # and home page) on small screens; other views show a "not supported on + # mobile" screen. Authoring features are hidden on mobile when enabled. + # @lifecycle: development + "MOBILE_CONSUMPTION_MODE": False, # Try to optimize SQL queries — for now only predicate pushdown is supported # @lifecycle: development "OPTIMIZE_SQL": False, diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 7cf9e49a0c8..844bc1b7d72 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -1063,6 +1063,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods @self.superset_app.context_processor def get_common_bootstrap_data() -> dict[str, Any]: # Import here to avoid circular imports + from superset.extensions import feature_flag_manager from superset.utils import json from superset.views.base import common_bootstrap_payload @@ -1072,7 +1073,10 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods default=json.pessimistic_json_iso_dttm_ser, ) - return {"bootstrap_data": serialize_bootstrap_data} + return { + "bootstrap_data": serialize_bootstrap_data, + "is_feature_enabled": feature_flag_manager.is_feature_enabled, + } def check_and_warn_database_connection(self) -> None: """Check database connection and warn if unavailable""" diff --git a/superset/templates/superset/spa.html b/superset/templates/superset/spa.html index 9aa12e6b0d3..e99bd824347 100644 --- a/superset/templates/superset/spa.html +++ b/superset/templates/superset/spa.html @@ -31,6 +31,13 @@ {% block head_meta %} + {% if is_feature_enabled is defined and is_feature_enabled('MOBILE_CONSUMPTION_MODE') %} + {# Without this, mobile browsers lay the page out at the ~980px legacy + viewport and the mobile consumption mode's breakpoints never match. + Only emitted when the flag is on so desktop-only deployments keep + the shrink-to-fit rendering on phones. #} + + {% endif %} {# Manifest is served dynamically by PwaManifestView so APPLICATION_ROOT and STATIC_ASSETS_PREFIX resolve at request time. Do NOT use `assets_prefix` here — that may point to a CDN host with no