mirror of
https://github.com/apache/superset.git
synced 2026-07-23 15:15:57 +00:00
Compare commits
1 Commits
misc-chart
...
mobile-das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a256eb04cd |
11
.github/workflows/superset-playwright.yml
vendored
11
.github/workflows/superset-playwright.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
89
docs/docs/using-superset/mobile-experience.mdx
Normal file
89
docs/docs/using-superset/mobile-experience.mdx
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: Mobile Experience
|
||||
sidebar_position: 7
|
||||
version: 1
|
||||
---
|
||||
|
||||
import useBaseUrl from "@docusaurus/useBaseUrl";
|
||||
|
||||
# Mobile Experience
|
||||
|
||||
Superset ships an optional, consumption-only mobile experience for viewing
|
||||
dashboards on phones and other small screens. When enabled, screens below
|
||||
768px wide get a layout built for touch: dashboards render their charts
|
||||
stacked full-width, navigation collapses into a drawer, and dashboard
|
||||
filters open in a slide-out panel.
|
||||
|
||||
The mobile experience is **read-only by design**. It is aimed at consumers
|
||||
of analytics — people checking a dashboard from a phone — not at dashboard
|
||||
authors. Authoring surfaces (chart builder, SQL Lab, dataset management,
|
||||
and administrative screens) remain desktop-only.
|
||||
|
||||
## Enabling the mobile experience
|
||||
|
||||
The mobile experience is gated behind the `MOBILE_CONSUMPTION_MODE` feature
|
||||
flag, which is off by default. Enable it in your `superset_config.py`:
|
||||
|
||||
```python
|
||||
FEATURE_FLAGS = {
|
||||
"MOBILE_CONSUMPTION_MODE": True,
|
||||
}
|
||||
```
|
||||
|
||||
With the flag disabled, Superset renders identically at every screen size,
|
||||
and phones display the desktop layout scaled down (the pre-existing
|
||||
behavior). The flag also controls whether Superset serves a viewport meta
|
||||
tag, which is required for mobile browsers to apply the responsive layout
|
||||
at their native width.
|
||||
|
||||
## What works on mobile
|
||||
|
||||
| Area | Mobile behavior |
|
||||
| --- | --- |
|
||||
| **Dashboards** | Charts stack vertically at full width, sized to the screen. Tab bars are sticky and swipeable. Native filters open in a drawer via the filter icon in the header. |
|
||||
| **Dashboard list** | Card view with full-width cards; search and filters open in a drawer. |
|
||||
| **Home** | Recents (dashboards only) and dashboard cards; desktop-only sections are hidden. |
|
||||
| **Navigation** | A hamburger menu opens a drawer with links to dashboards, theme and language selection, and user info/logout. |
|
||||
|
||||
<div style={{display: 'flex', gap: '1rem', flexWrap: 'wrap'}}>
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_dashboard.jpg")} alt="A dashboard on mobile with charts stacked full width" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_filter_drawer.jpg")} alt="The dashboard filter drawer on mobile" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_dashboard_list.jpg")} alt="The dashboard list in card view on mobile" width="260" />
|
||||
</div>
|
||||
|
||||
<div style={{display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '1rem'}}>
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_home.jpg")} alt="The Superset home page on mobile" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_nav_drawer.jpg")} alt="The mobile navigation drawer" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_unsupported.jpg")} alt="The screen shown for views that are not available on mobile" width="260" />
|
||||
</div>
|
||||
|
||||
## What doesn't work on mobile
|
||||
|
||||
Everything not listed above shows a friendly "This view isn't available on
|
||||
mobile" screen with shortcuts back to dashboards and the home page. That
|
||||
includes:
|
||||
|
||||
- Chart builder (Explore) and chart-level links — chart titles on
|
||||
dashboards are plain text on mobile, and chart entries are filtered out
|
||||
of the home page's Recents feed
|
||||
- SQL Lab and query history
|
||||
- Creating or editing dashboards, charts, datasets, and databases
|
||||
- List views other than dashboards (charts, datasets, saved queries, etc.)
|
||||
- Administrative and settings screens
|
||||
|
||||
Editing controls are also removed from the screens that *are* supported:
|
||||
the dashboard header hides the edit, publish, and favorite controls, and
|
||||
dashboard/chart kebab menus are reduced to view-oriented actions.
|
||||
|
||||
If a device crosses the 768px threshold — for example, rotating a tablet
|
||||
to landscape or resizing a window — the full desktop experience becomes
|
||||
available immediately.
|
||||
|
||||
## Notes for operators
|
||||
|
||||
- The flag is deployment-wide; there is no per-role or per-user targeting.
|
||||
- Dashboard permalinks and links shared from desktop resolve normally on
|
||||
mobile as long as they point at dashboards.
|
||||
- Embedded dashboards are unaffected: the embedded SDK controls its own
|
||||
layout, and the viewport meta tag is only interpreted by the top-level
|
||||
page.
|
||||
6
docs/static/feature-flags.json
vendored
6
docs/static/feature-flags.json
vendored
@@ -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,
|
||||
|
||||
BIN
docs/static/img/screenshots/mobile/mobile_dashboard.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_dashboard.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/static/img/screenshots/mobile/mobile_dashboard_list.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_dashboard_list.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/static/img/screenshots/mobile/mobile_filter_drawer.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_filter_drawer.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
docs/static/img/screenshots/mobile/mobile_home.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_home.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/static/img/screenshots/mobile/mobile_nav_drawer.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_nav_drawer.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/static/img/screenshots/mobile/mobile_unsupported.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_unsupported.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -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;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ReactNode, ReactElement, memo } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, SupersetTheme, useTheme } from '@apache-superset/core/theme';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { FeatureFlag, isFeatureEnabled } from '../../utils/featureFlags';
|
||||
import type { DropdownProps } from '../Dropdown/types';
|
||||
import type { TooltipPlacement } from '../Tooltip/types';
|
||||
import type { CertifiedBadgeProps } from '../CertifiedBadge/types';
|
||||
@@ -82,6 +83,20 @@ const headerStyles = (theme: SupersetTheme) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Mobile consumption mode: center the title between left/right panels */
|
||||
${
|
||||
isFeatureEnabled(FeatureFlag.MobileConsumptionMode) &&
|
||||
css`
|
||||
@media (max-width: ${theme.screenSMMax}px) {
|
||||
.title-panel {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
`;
|
||||
|
||||
const buttonsStyles = (theme: SupersetTheme) => css`
|
||||
@@ -109,6 +124,7 @@ export type PageHeaderWithActionsProps = {
|
||||
showFaveStar: boolean;
|
||||
showMenuDropdown?: boolean;
|
||||
faveStarProps: FaveStarProps;
|
||||
leftPanelItems?: ReactNode;
|
||||
titlePanelAdditionalItems: ReactNode;
|
||||
rightPanelAdditionalItems: ReactNode;
|
||||
additionalActionsMenu: ReactElement;
|
||||
@@ -126,6 +142,7 @@ export const PageHeaderWithActions = memo(
|
||||
certificatiedBadgeProps,
|
||||
showFaveStar,
|
||||
faveStarProps,
|
||||
leftPanelItems,
|
||||
titlePanelAdditionalItems,
|
||||
rightPanelAdditionalItems,
|
||||
additionalActionsMenu,
|
||||
@@ -136,6 +153,7 @@ export const PageHeaderWithActions = memo(
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<div css={headerStyles} className="header-with-actions">
|
||||
{leftPanelItems}
|
||||
<div className="title-panel">
|
||||
<DynamicEditableTitle {...editableTitleProps} />
|
||||
{showTitlePanelItems && (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mobile Experience Documentation Screenshot Generator
|
||||
*
|
||||
* Captures phone-sized screenshots for the mobile consumption mode docs
|
||||
* (docs/docs/using-superset/mobile-experience.mdx). Depends on example data
|
||||
* loaded via `superset load_examples` AND the MOBILE_CONSUMPTION_MODE
|
||||
* feature flag being enabled in the target environment:
|
||||
*
|
||||
* FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}
|
||||
*
|
||||
* Run locally:
|
||||
* cd superset-frontend
|
||||
* PLAYWRIGHT_BASE_URL=http://localhost:8088 PLAYWRIGHT_ADMIN_PASSWORD=admin npm run docs:screenshots
|
||||
*
|
||||
* Screenshots are saved under docs/static/img/screenshots/mobile/.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { Page, test, expect } from '@playwright/test';
|
||||
import { URL } from '../../utils/urls';
|
||||
|
||||
const MOBILE_SCREENSHOTS_DIR = path.resolve(
|
||||
__dirname,
|
||||
'../../../../docs/static/img/screenshots/mobile',
|
||||
);
|
||||
|
||||
// iPhone 12-class viewport; 2x scale factor for crisp docs images
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 2,
|
||||
hasTouch: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Waits for animations and async renders to settle before taking a
|
||||
* screenshot. ECharts entry animations, drawer transitions, and image
|
||||
* lazy-loading require a short pause that can't be expressed as a
|
||||
* deterministic wait condition.
|
||||
*/
|
||||
async function settle(page: Page, ms = 1000): Promise<void> {
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Sales Dashboard (from example data) at phone size and waits for
|
||||
* the stacked charts to finish rendering.
|
||||
*/
|
||||
async function openSalesDashboardMobile(page: Page): Promise<void> {
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
// Mobile list is card-only; cards navigate on tap (titles are plain
|
||||
// text, not links, in consumption mode)
|
||||
const dashboardCard = page.getByText('Sales Dashboard', { exact: true });
|
||||
await expect(dashboardCard.first()).toBeVisible({ timeout: 15000 });
|
||||
await dashboardCard.first().click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-test="dashboard-content-wrapper"]'),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder canvas').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
test('mobile dashboard screenshot', async ({ page }) => {
|
||||
await openSalesDashboardMobile(page);
|
||||
await settle(page, 2000);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_dashboard.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile dashboard filter drawer screenshot', async ({ page }) => {
|
||||
await openSalesDashboardMobile(page);
|
||||
|
||||
const filterTrigger = page.locator('[data-test="mobile-filters-trigger"]');
|
||||
await expect(filterTrigger).toBeVisible({ timeout: 15000 });
|
||||
await filterTrigger.click();
|
||||
|
||||
// Wait for the drawer and its filter controls to render
|
||||
await expect(page.locator('.ant-drawer-body')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.locator('[data-test="filter-bar"]')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
// Park the pointer so no hover card is open in the capture
|
||||
await page.mouse.move(5, 830);
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_filter_drawer.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile dashboard list screenshot', async ({ page }) => {
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
// Card view is forced on mobile; wait for cards to render
|
||||
await expect(page.locator('[data-test="styled-card"]').first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_dashboard_list.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile home screenshot', async ({ page }) => {
|
||||
await page.goto(URL.WELCOME);
|
||||
await expect(page.getByText('Recents')).toBeVisible({ timeout: 15000 });
|
||||
await settle(page, 2000);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_home.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile navigation drawer screenshot', async ({ page }) => {
|
||||
await page.goto(URL.WELCOME);
|
||||
await expect(page.getByText('Recents')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
await expect(menuButton).toBeVisible({ timeout: 10000 });
|
||||
await menuButton.click();
|
||||
|
||||
await expect(page.locator('.ant-drawer-body')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText('Dashboards').first()).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_nav_drawer.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile unsupported route screenshot', async ({ page }) => {
|
||||
await page.goto(URL.SQLLAB);
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_unsupported.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
150
superset-frontend/spec/helpers/mobileTestUtils.ts
Normal file
150
superset-frontend/spec/helpers/mobileTestUtils.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mobile testing utilities for Jest tests.
|
||||
*
|
||||
* Note: We mock 'antd' directly rather than '@superset-ui/core/components' because
|
||||
* mocking the latter causes circular dependency issues with ActionButton during
|
||||
* jest.requireActual evaluation. Since Grid is re-exported from antd, mocking
|
||||
* antd at the source works correctly.
|
||||
*/
|
||||
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
|
||||
/**
|
||||
* Standard mobile breakpoint values (below md breakpoint)
|
||||
*/
|
||||
export const mobileBreakpoints = {
|
||||
xs: true,
|
||||
sm: true,
|
||||
md: false,
|
||||
lg: false,
|
||||
xl: false,
|
||||
xxl: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard desktop breakpoint values (at or above md breakpoint)
|
||||
*/
|
||||
export const desktopBreakpoints = {
|
||||
xs: true,
|
||||
sm: true,
|
||||
md: true,
|
||||
lg: true,
|
||||
xl: true,
|
||||
xxl: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock for antd Grid.useBreakpoint that returns mobile breakpoints.
|
||||
* Use this at the top of test files that need to simulate mobile viewport.
|
||||
*
|
||||
* @example
|
||||
* jest.mock('antd', () => mockAntdWithMobileBreakpoint());
|
||||
*/
|
||||
export const mockAntdWithMobileBreakpoint = () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => mobileBreakpoints,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock for antd Grid.useBreakpoint that returns desktop breakpoints.
|
||||
* Use this at the top of test files that need to simulate desktop viewport.
|
||||
*
|
||||
* @example
|
||||
* jest.mock('antd', () => mockAntdWithDesktopBreakpoint());
|
||||
*/
|
||||
export const mockAntdWithDesktopBreakpoint = () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => desktopBreakpoints,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Mocks window.matchMedia so `(max-width: ...)` queries match, simulating
|
||||
* a mobile viewport for the useIsMobile hook. Returns a cleanup function
|
||||
* restoring the previous matchMedia. Mobile behavior requires BOTH this
|
||||
* AND the MOBILE_CONSUMPTION_MODE flag (see enableMobileConsumptionFlag).
|
||||
*/
|
||||
export const mockMobileMatchMedia = () => {
|
||||
const previous = window.matchMedia;
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes('max-width'),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
return () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: previous,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Enables the MOBILE_CONSUMPTION_MODE feature flag on window.featureFlags.
|
||||
* Mobile behavior requires BOTH a small viewport (mockMobileMatchMedia)
|
||||
* AND this flag; call this in beforeAll/beforeEach of mobile test suites.
|
||||
* Returns a cleanup function restoring the previous flags.
|
||||
*/
|
||||
export const enableMobileConsumptionFlag = () => {
|
||||
const previous = window.featureFlags;
|
||||
window.featureFlags = {
|
||||
...window.featureFlags,
|
||||
[FeatureFlag.MobileConsumptionMode]: true,
|
||||
};
|
||||
return () => {
|
||||
window.featureFlags = previous;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Common mobile viewport dimensions for reference
|
||||
*/
|
||||
export const mobileViewports = {
|
||||
iPhoneX: { width: 375, height: 812 },
|
||||
iPhoneSE: { width: 375, height: 667 },
|
||||
iPhone12Pro: { width: 390, height: 844 },
|
||||
pixel5: { width: 393, height: 851 },
|
||||
samsungGalaxyS20: { width: 360, height: 800 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Common tablet viewport dimensions for reference
|
||||
*/
|
||||
export const tabletViewports = {
|
||||
iPadMini: { width: 768, height: 1024 },
|
||||
iPadAir: { width: 820, height: 1180 },
|
||||
iPadPro11: { width: 834, height: 1194 },
|
||||
surfacePro7: { width: 912, height: 1368 },
|
||||
};
|
||||
@@ -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;
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -386,3 +386,227 @@ describe('ListView', () => {
|
||||
expect(screen.getByTestId('empty-state')).toHaveClass('card');
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile support tests
|
||||
test('respects forceViewMode prop and hides view toggle', () => {
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div>Card</div>}
|
||||
forceViewMode="card"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// View toggle should not be present when forceViewMode is set
|
||||
expect(screen.queryByLabelText('card-view')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('list-view')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows card view when forceViewMode is card', () => {
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div data-test="test-card">Card Content</div>}
|
||||
forceViewMode="card"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Should render cards, not table rows
|
||||
expect(screen.getAllByTestId('test-card')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('renders mobile filter drawer when mobileFiltersOpen is true', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search Dashboards"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Drawer should be visible with custom title
|
||||
expect(screen.getByText('Search Dashboards')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls setMobileFiltersOpen(false) when drawer is closed', async () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Click the close button on the drawer
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
expect(setMobileFiltersOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('mobile drawer contains FilterControls', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// The drawer should contain the filter controls; select filters render
|
||||
// as popover triggers (aria-haspopup="listbox") in the drawer
|
||||
const drawer = screen.getByRole('dialog');
|
||||
const filterTriggers = drawer.querySelectorAll('[aria-haspopup="listbox"]');
|
||||
expect(filterTriggers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('mobile drawer contains CardSortSelect when in card view with sort options', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...mockedPropsComprehensive}
|
||||
renderCard={() => <div>Card</div>}
|
||||
forceViewMode="card"
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
initialSort={[{ id: 'something' }]}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Sort select should be present (may be multiple - one in drawer, one in header)
|
||||
const sortSelects = screen.getAllByTestId('card-sort-select');
|
||||
expect(sortSelects.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('uses default drawer title when mobileFiltersDrawerTitle not provided', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Default title should be 'Search'
|
||||
expect(screen.getByText('Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render drawer when mobileFiltersOpen is false', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen={false}
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Drawer should not be visible (title not in visible content)
|
||||
// Note: Ant Design drawer might still be in DOM but hidden
|
||||
const drawer = document.querySelector('.ant-drawer-open');
|
||||
expect(drawer).toBeNull();
|
||||
});
|
||||
|
||||
test('does not render mobile drawer without setMobileFiltersOpen prop', () => {
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView {...propsWithoutSort} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// No drawer elements should exist
|
||||
const drawer = document.querySelector('.ant-drawer');
|
||||
expect(drawer).toBeNull();
|
||||
});
|
||||
|
||||
test('forceViewMode table shows table view', () => {
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div data-test="card">Card</div>}
|
||||
forceViewMode="table"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Should show table, not cards
|
||||
expect(screen.queryByTestId('card')).not.toBeInTheDocument();
|
||||
// Table should be present
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import BulkTagModal from 'src/features/tags/BulkTagModal';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
Drawer,
|
||||
Icons,
|
||||
EmptyState,
|
||||
Loading,
|
||||
@@ -239,6 +240,30 @@ const EmptyWrapper = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
const MobileFilterDrawerContent = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 4}px;
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
|
||||
/* Make filter inputs stack vertically and full-width */
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Override inline filter styling for vertical layout */
|
||||
.filter-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
.ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ViewModeToggle = ({
|
||||
mode,
|
||||
setMode,
|
||||
@@ -305,6 +330,7 @@ export interface ListViewProps<T extends object = any> {
|
||||
renderCard?: (row: T & { loading: boolean }) => ReactNode;
|
||||
cardSortSelectOptions?: Array<CardSortSelectOption>;
|
||||
defaultViewMode?: ViewModeType;
|
||||
forceViewMode?: ViewModeType;
|
||||
highlightRowId?: number;
|
||||
showThumbnails?: boolean;
|
||||
emptyState?: EmptyStateProps;
|
||||
@@ -320,6 +346,12 @@ export interface ListViewProps<T extends object = any> {
|
||||
expandable?: Record<string, unknown>;
|
||||
/** Content rendered between the filter bar and the table/card body. */
|
||||
headerContent?: ReactNode;
|
||||
/** Whether mobile filters drawer is open (controlled externally) */
|
||||
mobileFiltersOpen?: boolean;
|
||||
/** Callback to set mobile filters drawer open state */
|
||||
setMobileFiltersOpen?: (open: boolean) => void;
|
||||
/** Title for the mobile filters drawer */
|
||||
mobileFiltersDrawerTitle?: string;
|
||||
}
|
||||
|
||||
export function ListView<T extends object = any>({
|
||||
@@ -341,6 +373,7 @@ export function ListView<T extends object = any>({
|
||||
showThumbnails,
|
||||
cardSortSelectOptions,
|
||||
defaultViewMode = 'card',
|
||||
forceViewMode,
|
||||
highlightRowId,
|
||||
emptyState,
|
||||
columnsForWrapText,
|
||||
@@ -351,6 +384,9 @@ export function ListView<T extends object = any>({
|
||||
headerContent,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
mobileFiltersOpen = false,
|
||||
setMobileFiltersOpen,
|
||||
mobileFiltersDrawerTitle,
|
||||
}: ListViewProps<T>) {
|
||||
const {
|
||||
getTableProps,
|
||||
@@ -377,6 +413,7 @@ export function ListView<T extends object = any>({
|
||||
initialFilters: filters,
|
||||
renderCard: Boolean(renderCard),
|
||||
defaultViewMode,
|
||||
forceViewMode,
|
||||
});
|
||||
const allowBulkTagActions = bulkTagResourceName && enableBulkTag;
|
||||
const filterable = Boolean(filters.length);
|
||||
@@ -453,11 +490,15 @@ export function ListView<T extends object = any>({
|
||||
)}
|
||||
<div data-test={className} className={`superset-list-view ${className} `}>
|
||||
<div className="header">
|
||||
{cardViewEnabled && (
|
||||
{cardViewEnabled && !forceViewMode && (
|
||||
<ViewModeToggle mode={viewMode} setMode={setViewMode} />
|
||||
)}
|
||||
<div className="controls" data-test="filters-select">
|
||||
{filterable && (
|
||||
{/* When a mobile drawer callback is provided, filters and sort
|
||||
render inside the drawer instead of inline. Only one
|
||||
FilterControls instance is ever mounted, so filtersRef and
|
||||
filterControlsRef always point at the visible instance. */}
|
||||
{filterable && !setMobileFiltersOpen && (
|
||||
<FilterControls
|
||||
ref={filterControlsRef}
|
||||
filters={filters}
|
||||
@@ -465,14 +506,16 @@ export function ListView<T extends object = any>({
|
||||
updateFilterValue={applyFilterValue}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'card' && cardSortSelectOptions && (
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
{filterable && (
|
||||
{viewMode === 'card' &&
|
||||
cardSortSelectOptions &&
|
||||
!setMobileFiltersOpen && (
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
{filterable && !setMobileFiltersOpen && (
|
||||
<Tooltip
|
||||
title={!hasActiveFilters ? t('No filters applied') : undefined}
|
||||
>
|
||||
@@ -659,6 +702,40 @@ export function ListView<T extends object = any>({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile filter drawer */}
|
||||
{filterable && setMobileFiltersOpen && (
|
||||
<Drawer
|
||||
title={mobileFiltersDrawerTitle || t('Search')}
|
||||
placement="left"
|
||||
onClose={() => setMobileFiltersOpen(false)}
|
||||
open={mobileFiltersOpen}
|
||||
width={300}
|
||||
>
|
||||
<MobileFilterDrawerContent>
|
||||
<FilterControls
|
||||
ref={filterControlsRef}
|
||||
filters={filters}
|
||||
internalFilters={internalFilters}
|
||||
updateFilterValue={applyFilterValue}
|
||||
/>
|
||||
{viewMode === 'card' && cardSortSelectOptions && (
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
<ClearAllButton
|
||||
type="button"
|
||||
disabled={!hasActiveFilters}
|
||||
onClick={() => filterControlsRef.current?.clearFilters()}
|
||||
>
|
||||
{t('Clear all')}
|
||||
</ClearAllButton>
|
||||
</MobileFilterDrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</ListViewStyles>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ interface UseListViewConfig {
|
||||
initialFilters?: Filter[];
|
||||
renderCard?: boolean;
|
||||
defaultViewMode?: ViewModeType;
|
||||
forceViewMode?: ViewModeType;
|
||||
}
|
||||
|
||||
export function useListViewState({
|
||||
@@ -207,6 +208,7 @@ export function useListViewState({
|
||||
initialSort = [],
|
||||
renderCard = false,
|
||||
defaultViewMode = 'card',
|
||||
forceViewMode,
|
||||
}: UseListViewConfig) {
|
||||
const [query, setQuery] = useQueryParams({
|
||||
filters: RisonParam,
|
||||
@@ -234,10 +236,19 @@ export function useListViewState({
|
||||
};
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewModeType>(
|
||||
(query.viewMode as ViewModeType) ||
|
||||
// forceViewMode overrides everything (used for mobile)
|
||||
forceViewMode ||
|
||||
(query.viewMode as ViewModeType) ||
|
||||
(renderCard ? defaultViewMode : 'table'),
|
||||
);
|
||||
|
||||
// Update viewMode when forceViewMode changes (e.g., screen resize)
|
||||
useEffect(() => {
|
||||
if (forceViewMode) {
|
||||
setViewMode(forceViewMode);
|
||||
}
|
||||
}, [forceViewMode]);
|
||||
|
||||
const columnsWithFilter = useMemo(
|
||||
// add exact filter type so filters with falsy values are not filtered out
|
||||
() => columns.map(f => ({ ...f, filter: 'exact' })),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import MobileRouteGuard from '.';
|
||||
|
||||
jest.mock('src/hooks/useIsMobile', () => ({
|
||||
useIsMobile: jest.fn(),
|
||||
isMobileConsumptionEnabled: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const mockedUseIsMobile = useIsMobile as jest.MockedFunction<
|
||||
typeof useIsMobile
|
||||
>;
|
||||
|
||||
const renderGuard = (mobileSupported?: boolean) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/some/route/']}>
|
||||
<MobileRouteGuard mobileSupported={mobileSupported}>
|
||||
<div data-test="guarded-content">Content</div>
|
||||
</MobileRouteGuard>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUseIsMobile.mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('renders children on desktop regardless of mobileSupported', () => {
|
||||
renderGuard(undefined);
|
||||
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders children on mobile when the route is mobileSupported', () => {
|
||||
mockedUseIsMobile.mockReturnValue(true);
|
||||
renderGuard(true);
|
||||
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows the unsupported screen on mobile for unsupported routes', () => {
|
||||
mockedUseIsMobile.mockReturnValue(true);
|
||||
renderGuard(undefined);
|
||||
expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("This view isn't available on mobile"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
52
superset-frontend/src/components/MobileRouteGuard/index.tsx
Normal file
52
superset-frontend/src/components/MobileRouteGuard/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode } from 'react';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import MobileUnsupported from 'src/pages/MobileUnsupported';
|
||||
|
||||
interface MobileRouteGuardProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Whether the wrapped route is part of the mobile consumption
|
||||
* experience. Set via the `mobileSupported` flag on the route
|
||||
* definition in `src/views/routes.tsx`.
|
||||
*/
|
||||
mobileSupported?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps route content and shows the MobileUnsupported page when a
|
||||
* non-mobile-friendly route is accessed on a small screen with
|
||||
* MOBILE_CONSUMPTION_MODE enabled. Growing the viewport past the
|
||||
* breakpoint unblocks the route automatically.
|
||||
*/
|
||||
function MobileRouteGuard({
|
||||
children,
|
||||
mobileSupported,
|
||||
}: MobileRouteGuardProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!isMobile || mobileSupported) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <MobileUnsupported />;
|
||||
}
|
||||
|
||||
export default MobileRouteGuard;
|
||||
@@ -50,6 +50,15 @@ fetchMock.put('glob:*/api/v1/dashboard/*', {});
|
||||
// Add mock for logging endpoint
|
||||
fetchMock.post('glob:*/log/?*', {});
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/actions/dashboardState', () => ({
|
||||
...jest.requireActual('src/dashboard/actions/dashboardState'),
|
||||
fetchFaveStar: jest.fn(),
|
||||
@@ -422,6 +431,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
@@ -448,6 +458,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: false,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
@@ -474,6 +485,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: false,
|
||||
hasFilters: false,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
@@ -533,6 +545,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: false,
|
||||
hasFilters: false,
|
||||
});
|
||||
const { queryByTestId } = setup();
|
||||
|
||||
@@ -546,6 +559,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
const { queryByTestId } = setup();
|
||||
|
||||
@@ -559,6 +573,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
const { queryByTestId } = setup({
|
||||
dashboardState: { ...mockState.dashboardState, editMode: true },
|
||||
@@ -719,3 +734,85 @@ test('should maintain layout when switching between tabs', async () => {
|
||||
expect(gridContainer).toBeInTheDocument();
|
||||
expect(tabPanels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Mobile support tests
|
||||
// Note: The main mobile tests require mocking useBreakpoint to return mobile breakpoints
|
||||
// which is done at the module level. These tests verify mobile-related component behavior.
|
||||
|
||||
test('should not render filter bar panel on desktop when nativeFiltersEnabled is false', () => {
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
100,
|
||||
jest.fn(),
|
||||
]);
|
||||
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
|
||||
jest.spyOn(useNativeFiltersModule, 'useNativeFilters').mockReturnValue({
|
||||
showDashboard: true,
|
||||
missingInitialFilters: [],
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: false,
|
||||
hasFilters: false,
|
||||
});
|
||||
|
||||
const { queryByTestId } = render(<DashboardBuilder />, {
|
||||
useRedux: true,
|
||||
store: storeWithState({
|
||||
...mockState,
|
||||
dashboardLayout: undoableDashboardLayout,
|
||||
}),
|
||||
useDnd: true,
|
||||
useTheme: true,
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
// Filter panel should not be present when native filters are disabled
|
||||
expect(queryByTestId('dashboard-filters-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render dashboard content wrapper', () => {
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
100,
|
||||
jest.fn(),
|
||||
]);
|
||||
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
|
||||
const { getByTestId } = render(<DashboardBuilder />, {
|
||||
useRedux: true,
|
||||
store: storeWithState({
|
||||
...mockState,
|
||||
dashboardLayout: undoableDashboardLayout,
|
||||
}),
|
||||
useDnd: true,
|
||||
useTheme: true,
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
// Dashboard content wrapper should always be present
|
||||
expect(getByTestId('dashboard-content-wrapper')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render header container', () => {
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
100,
|
||||
jest.fn(),
|
||||
]);
|
||||
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
|
||||
const { queryByTestId } = render(<DashboardBuilder />, {
|
||||
useRedux: true,
|
||||
store: storeWithState({
|
||||
...mockState,
|
||||
dashboardLayout: undoableDashboardLayout,
|
||||
}),
|
||||
useDnd: true,
|
||||
useTheme: true,
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
// Header container should be present
|
||||
expect(queryByTestId('dashboard-header-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { addAlpha, JsonObject, useElementOnScreen } from '@superset-ui/core';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { Drawer, EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { ErrorBoundary, BasicErrorAlert } from 'src/components';
|
||||
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
|
||||
import DashboardHeader from 'src/dashboard/components/Header';
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from 'src/dashboard/util/constants';
|
||||
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
|
||||
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||
import { isMobileConsumptionEnabled, useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import ResizableSidebar from 'src/components/ResizableSidebar';
|
||||
import {
|
||||
BUILDER_SIDEPANEL_WIDTH,
|
||||
@@ -101,6 +102,19 @@ const StyledHeader = styled.div<{ filterBarWidth: number }>`
|
||||
z-index: 99;
|
||||
max-width: calc(100vw - ${filterBarWidth}px);
|
||||
|
||||
/* Mobile consumption mode: let the dashboard title scroll away and keep
|
||||
only the tab bar sticky. A pinned title would sit underneath the
|
||||
higher-z sticky tabs, leaving its bottom edge (kebab button) peeking
|
||||
out below the tab bar. */
|
||||
${
|
||||
isMobileConsumptionEnabled() &&
|
||||
css`
|
||||
@media (max-width: ${theme.screenSMMax}px) {
|
||||
position: relative;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
.empty-droptarget {
|
||||
min-height: ${theme.sizeUnit * 4}px;
|
||||
}
|
||||
@@ -372,6 +386,8 @@ const DashboardBuilder = () => {
|
||||
const dispatch = useDispatch();
|
||||
const uiConfig = useUiConfig();
|
||||
const theme = useTheme();
|
||||
const isNotMobile = !useIsMobile();
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
|
||||
const dashboardId = useSelector<RootState, string>(
|
||||
({ dashboardInfo }) => `${dashboardInfo.id}`,
|
||||
@@ -462,13 +478,14 @@ const DashboardBuilder = () => {
|
||||
dashboardFiltersOpen,
|
||||
toggleDashboardFiltersOpen,
|
||||
nativeFiltersEnabled,
|
||||
hasFilters,
|
||||
} = useNativeFilters();
|
||||
|
||||
const [containerRef, isSticky] = useElementOnScreen<HTMLDivElement>(
|
||||
ELEMENT_ON_SCREEN_OPTIONS,
|
||||
);
|
||||
|
||||
const showFilterBar = !editMode && nativeFiltersEnabled;
|
||||
const showFilterBar = isNotMobile && !editMode && nativeFiltersEnabled;
|
||||
|
||||
const offset =
|
||||
FILTER_BAR_HEADER_HEIGHT +
|
||||
@@ -480,6 +497,7 @@ const DashboardBuilder = () => {
|
||||
const draggableStyle = useMemo(
|
||||
() => ({
|
||||
marginLeft:
|
||||
!isNotMobile ||
|
||||
dashboardFiltersOpen ||
|
||||
editMode ||
|
||||
!nativeFiltersEnabled ||
|
||||
@@ -488,6 +506,7 @@ const DashboardBuilder = () => {
|
||||
: -32,
|
||||
}),
|
||||
[
|
||||
isNotMobile,
|
||||
dashboardFiltersOpen,
|
||||
editMode,
|
||||
filterBarOrientation,
|
||||
@@ -519,7 +538,15 @@ const DashboardBuilder = () => {
|
||||
const headerContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{!hideDashboardHeader && <DashboardHeader />}
|
||||
{!hideDashboardHeader && (
|
||||
<DashboardHeader
|
||||
onOpenMobileFilters={
|
||||
!isNotMobile && nativeFiltersEnabled && hasFilters
|
||||
? () => setMobileFiltersOpen(true)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* Report mode is a one-shot screenshot render (reports, thumbnails),
|
||||
so it must never start a refresh timer that could re-fetch charts
|
||||
mid-capture. */}
|
||||
@@ -533,7 +560,15 @@ const DashboardBuilder = () => {
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[hideDashboardHeader, showFilterBar, filterBarOrientation, isReport],
|
||||
[
|
||||
hideDashboardHeader,
|
||||
isNotMobile,
|
||||
nativeFiltersEnabled,
|
||||
hasFilters,
|
||||
showFilterBar,
|
||||
filterBarOrientation,
|
||||
isReport,
|
||||
],
|
||||
);
|
||||
|
||||
const renderDraggableContent = useCallback(
|
||||
@@ -577,6 +612,8 @@ const DashboardBuilder = () => {
|
||||
topLevelTabs,
|
||||
uiConfig.hideTab,
|
||||
uiConfig.hideNav,
|
||||
isNotMobile,
|
||||
theme,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -739,6 +776,36 @@ const DashboardBuilder = () => {
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{/* Mobile filters drawer */}
|
||||
{!isNotMobile && nativeFiltersEnabled && (
|
||||
<Drawer
|
||||
title={t('Filters')}
|
||||
placement="left"
|
||||
onClose={() => setMobileFiltersOpen(false)}
|
||||
open={mobileFiltersOpen}
|
||||
width="85vw"
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<FilterBar
|
||||
orientation={FilterBarOrientation.Vertical}
|
||||
verticalConfig={{
|
||||
filtersOpen: true,
|
||||
toggleFiltersBar: () => {},
|
||||
width: 300,
|
||||
height: '100%',
|
||||
offset: 0,
|
||||
mobileMode: true,
|
||||
}}
|
||||
hidden={false}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
</DashboardWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -121,5 +121,6 @@ export const useNativeFilters = () => {
|
||||
dashboardFiltersOpen,
|
||||
toggleDashboardFiltersOpen,
|
||||
nativeFiltersEnabled,
|
||||
hasFilters: filterValues.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -23,7 +23,12 @@ import {
|
||||
FeatureFlag,
|
||||
getExtensionsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { styled, css, SupersetTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
styled,
|
||||
css,
|
||||
SupersetTheme,
|
||||
useTheme,
|
||||
} from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Global } from '@emotion/react';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
@@ -37,6 +42,7 @@ import {
|
||||
UnsavedChangesModal,
|
||||
} from '@superset-ui/core/components';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
import Subject from 'src/types/Subject';
|
||||
import { DashboardLayout, RootState } from 'src/dashboard/types';
|
||||
@@ -219,8 +225,14 @@ const discardChanges = () => {
|
||||
window.location.assign(url);
|
||||
};
|
||||
|
||||
const Header = (): JSX.Element => {
|
||||
interface HeaderComponentProps {
|
||||
onOpenMobileFilters?: () => void;
|
||||
}
|
||||
|
||||
const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const [didNotifyMaxUndoHistoryToast, setDidNotifyMaxUndoHistoryToast] =
|
||||
useState(false);
|
||||
const [emphasizeUndo, setEmphasizeUndo] = useState(false);
|
||||
@@ -631,7 +643,8 @@ const Header = (): JSX.Element => {
|
||||
|
||||
const titlePanelAdditionalItems = useMemo(
|
||||
() => [
|
||||
!editMode && (
|
||||
// The kebab menu's "Refresh dashboard" item covers this on mobile
|
||||
!editMode && !isMobile && (
|
||||
<RefreshButton key="refresh-button" onRefresh={forceRefresh} />
|
||||
),
|
||||
!editMode && (
|
||||
@@ -640,7 +653,7 @@ const Header = (): JSX.Element => {
|
||||
onTogglePause={handlePauseToggle}
|
||||
/>
|
||||
),
|
||||
!editMode && (
|
||||
!editMode && !isMobile && (
|
||||
<PublishedStatus
|
||||
key="published-status"
|
||||
dashboardId={dashboardInfo.id}
|
||||
@@ -650,12 +663,13 @@ const Header = (): JSX.Element => {
|
||||
userCanSave={userCanSaveAs}
|
||||
/>
|
||||
),
|
||||
!editMode && !isEmbedded && metadataBar,
|
||||
!editMode && !isEmbedded && !isMobile && metadataBar,
|
||||
],
|
||||
[
|
||||
boundActionCreators.savePublished,
|
||||
dashboardInfo.id,
|
||||
editMode,
|
||||
isMobile,
|
||||
metadataBar,
|
||||
isEmbedded,
|
||||
isPublished,
|
||||
@@ -751,7 +765,7 @@ const Header = (): JSX.Element => {
|
||||
) : (
|
||||
<div css={actionButtonsStyle}>
|
||||
{NavExtension && <NavExtension />}
|
||||
{userCanEdit && !isEmbedded && (
|
||||
{userCanEdit && !isEmbedded && !isMobile && (
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={handleEnterEditMode}
|
||||
@@ -779,6 +793,7 @@ const Header = (): JSX.Element => {
|
||||
handleEnterEditMode,
|
||||
hasUnsavedChanges,
|
||||
isEmbedded,
|
||||
isMobile,
|
||||
overwriteDashboard,
|
||||
redoLength,
|
||||
undoLength,
|
||||
@@ -815,6 +830,10 @@ const Header = (): JSX.Element => {
|
||||
userCanCurate,
|
||||
userCanExport,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isStarred,
|
||||
isPublished,
|
||||
saveFaveStar: boundActionCreators.saveFaveStar,
|
||||
showReportModal,
|
||||
showPropertiesModal,
|
||||
showRefreshModal,
|
||||
@@ -834,6 +853,21 @@ const Header = (): JSX.Element => {
|
||||
editableTitleProps={editableTitleProps}
|
||||
certificatiedBadgeProps={certifiedBadgeProps}
|
||||
faveStarProps={faveStarProps}
|
||||
leftPanelItems={
|
||||
onOpenMobileFilters && (
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
aria-label={t('Open filters')}
|
||||
onClick={onOpenMobileFilters}
|
||||
data-test="mobile-filters-trigger"
|
||||
>
|
||||
<Icons.FilterOutlined
|
||||
iconColor={theme.colorPrimary}
|
||||
iconSize="l"
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
titlePanelAdditionalItems={titlePanelAdditionalItems}
|
||||
rightPanelAdditionalItems={rightPanelAdditionalItems}
|
||||
menuDropdownProps={{
|
||||
@@ -841,7 +875,7 @@ const Header = (): JSX.Element => {
|
||||
onOpenChange: setIsDropdownVisible,
|
||||
}}
|
||||
additionalActionsMenu={menu}
|
||||
showFaveStar={Boolean(user?.userId && dashboardInfo?.id)}
|
||||
showFaveStar={!!(user?.userId && dashboardInfo?.id && !isMobile)}
|
||||
showTitlePanelItems
|
||||
/>
|
||||
{showingPropertiesModal && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,6 +43,7 @@ import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import { Tooltip, EditableTitle, Icons } from '@superset-ui/core/components';
|
||||
import { useSelector } from 'react-redux';
|
||||
import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types';
|
||||
import MemoizedFiltersBadge from 'src/dashboard/components/FiltersBadge';
|
||||
import MemoizedCustomizationsBadge from 'src/dashboard/components/CustomizationsBadge';
|
||||
@@ -229,7 +230,9 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
0,
|
||||
);
|
||||
|
||||
const canExplore = !editMode && supersetCanExplore;
|
||||
// Consumption-only mobile mode: no explore link, no chart controls
|
||||
const isMobile = useIsMobile();
|
||||
const canExplore = !editMode && supersetCanExplore && !isMobile;
|
||||
const showRowLimitWarning =
|
||||
shouldShowRowLimitWarning && sqlRowCount >= rowLimit && rowLimit > 0;
|
||||
|
||||
@@ -355,7 +358,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!uiConfig.hideChartControls && (
|
||||
{!uiConfig.hideChartControls && !isMobile && (
|
||||
<SliceHeaderControls
|
||||
slice={slice}
|
||||
isCached={isCached}
|
||||
|
||||
@@ -36,13 +36,19 @@ import { AntdThemeProvider } from '@superset-ui/core/components';
|
||||
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
|
||||
import {
|
||||
GRID_BASE_UNIT,
|
||||
GRID_COLUMN_COUNT,
|
||||
GRID_GUTTER_SIZE,
|
||||
GRID_MIN_COLUMN_COUNT,
|
||||
GRID_MIN_ROW_UNITS,
|
||||
} from 'src/dashboard/util/constants';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
|
||||
export const CHART_MARGIN = 32;
|
||||
|
||||
// Vertical space reserved for app chrome (main nav + dashboard header +
|
||||
// tab bar) when capping chart heights to the viewport on mobile.
|
||||
export const MOBILE_CHROME_HEIGHT = 160;
|
||||
|
||||
export interface ChartHolderProps {
|
||||
id: string;
|
||||
parentId: string;
|
||||
@@ -96,6 +102,7 @@ const ChartHolder = ({
|
||||
isInView,
|
||||
}: ChartHolderProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const fullSizeStyle = css`
|
||||
&& {
|
||||
position: fixed !important;
|
||||
@@ -167,6 +174,14 @@ const ChartHolder = ({
|
||||
}, [outlinedComponentId]);
|
||||
|
||||
const widthMultiple = useMemo(() => {
|
||||
// Mobile consumption mode stacks charts vertically at full width, so
|
||||
// report the full column count. This keeps the pixel width handed to the
|
||||
// chart plugin (and to ResizableContainer's inline size) in sync with the
|
||||
// stacked layout instead of the desktop grid fraction.
|
||||
if (isMobile && !editMode) {
|
||||
return GRID_COLUMN_COUNT;
|
||||
}
|
||||
|
||||
const columnParentWidth = getComponentById(
|
||||
parentComponent.parents?.find(parent => parent.startsWith(COLUMN_TYPE)),
|
||||
)?.meta?.width;
|
||||
@@ -182,11 +197,29 @@ const ChartHolder = ({
|
||||
}, [
|
||||
component,
|
||||
getComponentById,
|
||||
isMobile,
|
||||
editMode,
|
||||
parentComponent.meta.width,
|
||||
parentComponent.parents,
|
||||
parentComponent.type,
|
||||
]);
|
||||
|
||||
// Grid units of height for this chart. In mobile consumption mode the
|
||||
// authored desktop height is capped to the viewport (minus app chrome) so
|
||||
// tall charts don't dominate the single-column stacked layout. Used for
|
||||
// both the ResizableContainer shell and the height handed to the plugin,
|
||||
// so the two can't disagree.
|
||||
const heightMultiple = useMemo(() => {
|
||||
const authoredHeight = component.meta.height ?? GRID_MIN_ROW_UNITS;
|
||||
if (isMobile && !editMode) {
|
||||
const maxUnits = Math.floor(
|
||||
(window.innerHeight - MOBILE_CHROME_HEIGHT) / GRID_BASE_UNIT,
|
||||
);
|
||||
return Math.max(GRID_MIN_ROW_UNITS, Math.min(authoredHeight, maxUnits));
|
||||
}
|
||||
return authoredHeight;
|
||||
}, [component.meta.height, isMobile, editMode]);
|
||||
|
||||
const { chartWidth, chartHeight } = useMemo(() => {
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
@@ -200,16 +233,14 @@ const ChartHolder = ({
|
||||
(widthMultiple - 1) * GRID_GUTTER_SIZE -
|
||||
CHART_MARGIN,
|
||||
);
|
||||
height = Math.floor(
|
||||
(component.meta.height ?? 0) * GRID_BASE_UNIT - CHART_MARGIN,
|
||||
);
|
||||
height = Math.floor(heightMultiple * GRID_BASE_UNIT - CHART_MARGIN);
|
||||
}
|
||||
|
||||
return {
|
||||
chartWidth: width,
|
||||
chartHeight: height,
|
||||
};
|
||||
}, [columnWidth, component, isFullSize, widthMultiple]);
|
||||
}, [columnWidth, heightMultiple, isFullSize, widthMultiple]);
|
||||
|
||||
const handleDeleteComponent = useCallback(() => {
|
||||
deleteComponent(id, parentId);
|
||||
@@ -250,7 +281,7 @@ const ChartHolder = ({
|
||||
widthStep={columnWidth}
|
||||
widthMultiple={widthMultiple}
|
||||
heightStep={GRID_BASE_UNIT}
|
||||
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
|
||||
heightMultiple={heightMultiple}
|
||||
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
|
||||
minHeightMultiple={GRID_MIN_ROW_UNITS}
|
||||
maxWidthMultiple={availableColumnCount + widthMultiple}
|
||||
@@ -342,12 +373,12 @@ const ChartHolder = ({
|
||||
),
|
||||
[
|
||||
component.id,
|
||||
component.meta.height,
|
||||
component.meta.chartId,
|
||||
component.meta.sliceNameOverride,
|
||||
component.meta.sliceName,
|
||||
parentComponent.type,
|
||||
columnWidth,
|
||||
heightMultiple,
|
||||
widthMultiple,
|
||||
availableColumnCount,
|
||||
onResizeStart,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { useSelector } from 'react-redux';
|
||||
import cx from 'classnames';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { DataMaskStateWithId } from '@superset-ui/core';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
@@ -141,6 +141,7 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
|
||||
onPendingCustomizationDataMaskChange,
|
||||
toggleFiltersBar,
|
||||
width,
|
||||
mobileMode,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
@@ -260,33 +261,56 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
|
||||
{...getFilterBarTestId()}
|
||||
className={cx({ open: filtersOpen })}
|
||||
width={width}
|
||||
css={
|
||||
mobileMode &&
|
||||
css`
|
||||
width: 100%;
|
||||
&.open {
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
}
|
||||
>
|
||||
<CollapsedBar
|
||||
{...getFilterBarTestId('collapsable')}
|
||||
className={cx({ open: !filtersOpen })}
|
||||
onClick={openFiltersBar}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
offset={offset}
|
||||
{!mobileMode && (
|
||||
<CollapsedBar
|
||||
{...getFilterBarTestId('collapsable')}
|
||||
className={cx({ open: !filtersOpen })}
|
||||
onClick={openFiltersBar}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
offset={offset}
|
||||
>
|
||||
<Icons.VerticalAlignTopOutlined
|
||||
iconSize="l"
|
||||
css={{
|
||||
transform: 'rotate(90deg)',
|
||||
marginBottom: `${theme.sizeUnit * 3}px`,
|
||||
}}
|
||||
className="collapse-icon"
|
||||
iconColor={theme.colorPrimary}
|
||||
{...getFilterBarTestId('expand-button')}
|
||||
/>
|
||||
<Icons.FilterOutlined
|
||||
{...getFilterBarTestId('filter-icon')}
|
||||
iconColor={theme.colorTextTertiary}
|
||||
iconSize="l"
|
||||
/>
|
||||
</CollapsedBar>
|
||||
)}
|
||||
<Bar
|
||||
className={cx({ open: filtersOpen })}
|
||||
width={width}
|
||||
css={
|
||||
mobileMode &&
|
||||
css`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
`
|
||||
}
|
||||
>
|
||||
<Icons.VerticalAlignTopOutlined
|
||||
iconSize="l"
|
||||
css={{
|
||||
transform: 'rotate(90deg)',
|
||||
marginBottom: `${theme.sizeUnit * 3}px`,
|
||||
}}
|
||||
className="collapse-icon"
|
||||
iconColor={theme.colorPrimary}
|
||||
{...getFilterBarTestId('expand-button')}
|
||||
/>
|
||||
<Icons.FilterOutlined
|
||||
{...getFilterBarTestId('filter-icon')}
|
||||
iconColor={theme.colorTextTertiary}
|
||||
iconSize="l"
|
||||
/>
|
||||
</CollapsedBar>
|
||||
<Bar className={cx({ open: filtersOpen })} width={width}>
|
||||
<Header toggleFiltersBar={toggleFiltersBar} />
|
||||
{!mobileMode && <Header toggleFiltersBar={toggleFiltersBar} />}
|
||||
{!isInitialized ? (
|
||||
<div
|
||||
css={{
|
||||
|
||||
@@ -680,6 +680,7 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
}
|
||||
toggleFiltersBar={verticalConfig.toggleFiltersBar}
|
||||
width={verticalConfig.width}
|
||||
mobileMode={verticalConfig.mobileMode}
|
||||
clearAllTriggers={clearAllTriggers}
|
||||
onClearAllComplete={handleClearAllComplete}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -38,6 +38,7 @@ import { SubjectPile } from 'src/features/subjects/SubjectPile';
|
||||
import { KebabMenuButton } from 'src/components';
|
||||
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
|
||||
interface DashboardCardProps {
|
||||
isChart?: boolean;
|
||||
@@ -67,6 +68,7 @@ function DashboardCard({
|
||||
onDelete,
|
||||
}: DashboardCardProps) {
|
||||
const userId = user?.userId;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const history = useHistory();
|
||||
const canEdit = hasPerm('can_write');
|
||||
@@ -212,10 +214,12 @@ function DashboardCard({
|
||||
isStarred={favoriteStatus}
|
||||
/>
|
||||
)}
|
||||
<KebabMenuButton
|
||||
menuItems={menuItems}
|
||||
dataTest="dashboard-card-menu"
|
||||
/>
|
||||
{!isMobile && (
|
||||
<KebabMenuButton
|
||||
menuItems={menuItems}
|
||||
dataTest="dashboard-card-menu"
|
||||
/>
|
||||
)}
|
||||
</ListViewCard.Actions>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from 'src/views/CRUD/utils';
|
||||
import { Chart } from 'src/types/Chart';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import SubMenu from './SubMenu';
|
||||
import EmptyState from './EmptyState';
|
||||
import { WelcomeTable, RecentActivity } from './types';
|
||||
@@ -70,6 +71,12 @@ const Styles = styled.div`
|
||||
const UNTITLED = t('[Untitled]');
|
||||
const UNKNOWN_TIME = t('Unknown');
|
||||
|
||||
// Dashboards are the only activity entities viewable in the mobile
|
||||
// consumption experience; charts and saved queries link to unsupported views
|
||||
const isDashboardEntity = (entity: ActivityObject) =>
|
||||
'dashboard_title' in entity ||
|
||||
('item_type' in entity && entity.item_type === 'dashboard');
|
||||
|
||||
const getEntityTitle = (entity: ActivityObject) => {
|
||||
if ('dashboard_title' in entity) return entity.dashboard_title || UNTITLED;
|
||||
if ('slice_name' in entity) return entity.slice_name || UNTITLED;
|
||||
@@ -121,6 +128,7 @@ export default function ActivityTable({
|
||||
}: ActivityProps) {
|
||||
const [editedCards, setEditedCards] = useState<ActivityData[]>();
|
||||
const [isFetchingEditedCards, setIsFetchingEditedCards] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -174,12 +182,16 @@ export default function ActivityTable({
|
||||
},
|
||||
});
|
||||
}
|
||||
const renderActivity = () => {
|
||||
const activities =
|
||||
(activeChild === TableTab.Edited
|
||||
? editedCards
|
||||
: activityData[activeChild as keyof ActivityData]) ?? [];
|
||||
return activities.map((entity: ActivityObject) => {
|
||||
const rawActivities =
|
||||
(activeChild === TableTab.Edited
|
||||
? editedCards
|
||||
: activityData[activeChild as keyof ActivityData]) ?? [];
|
||||
const activities = isMobile
|
||||
? rawActivities.filter(isDashboardEntity)
|
||||
: rawActivities;
|
||||
|
||||
const renderActivity = () =>
|
||||
activities.map((entity: ActivityObject) => {
|
||||
const url = getEntityUrl(entity);
|
||||
const lastActionOn = getEntityLastActionOn(entity);
|
||||
return (
|
||||
@@ -197,7 +209,6 @@ export default function ActivityTable({
|
||||
</CardStyles>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if ((isFetchingEditedCards && !editedCards) || isFetchingActivityData) {
|
||||
return <LoadingCards />;
|
||||
@@ -209,8 +220,7 @@ export default function ActivityTable({
|
||||
tabs={tabs}
|
||||
backgroundColor="transparent"
|
||||
/>
|
||||
{Number(activityData[activeChild as keyof ActivityData]?.length) > 0 ||
|
||||
(activeChild === TableTab.Edited && editedCards?.length) ? (
|
||||
{activities.length > 0 ? (
|
||||
<CardContainer className="recentCards">
|
||||
{renderActivity()}
|
||||
</CardContainer>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { TableTab } from 'src/views/CRUD/types';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import { WelcomeTable } from './types';
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
@@ -76,12 +77,19 @@ export interface EmptyStateProps {
|
||||
}
|
||||
|
||||
export default function EmptyState({ tableName, tab }: EmptyStateProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const getActionButton = () => {
|
||||
if (tableName === WelcomeTable.Recents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isFavorite = tab === TableTab.Favorite;
|
||||
|
||||
// Creation flows aren't part of the mobile consumption experience;
|
||||
// only offer the "See all ..." navigation there
|
||||
if (isMobile && !isFavorite) {
|
||||
return null;
|
||||
}
|
||||
const buttonText = isFavorite
|
||||
? LABELS.viewAll[tableName]
|
||||
: LABELS.create[tableName];
|
||||
@@ -111,7 +119,7 @@ export default function EmptyState({ tableName, tab }: EmptyStateProps) {
|
||||
<EmptyContainer>
|
||||
<EmptyStateComponent
|
||||
image={image}
|
||||
size="large"
|
||||
size={isMobile ? 'small' : 'large'}
|
||||
description={t('Nothing here yet')}
|
||||
>
|
||||
{getActionButton()}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import {
|
||||
MenuObjectChildProps,
|
||||
@@ -199,6 +200,7 @@ export function Menu({
|
||||
isFrontendRoute = () => false,
|
||||
}: MenuProps) {
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = useIsMobile();
|
||||
const uiConfig = useUiConfig();
|
||||
const theme = useTheme();
|
||||
// screens.md is undefined on the first render before breakpoints are measured;
|
||||
@@ -405,7 +407,18 @@ export function Menu({
|
||||
aria-label={t('Main navigation')}
|
||||
>
|
||||
<StyledRow>
|
||||
<StyledCol md={16} xs={24}>
|
||||
{/* Mobile: left placeholder for future icon */}
|
||||
{isMobile && <Col xs={4} />}
|
||||
<StyledCol
|
||||
md={16}
|
||||
xs={isMobile ? 16 : 24}
|
||||
css={
|
||||
isMobile &&
|
||||
css`
|
||||
justify-content: center;
|
||||
`
|
||||
}
|
||||
>
|
||||
{!brand.hide_logo && (
|
||||
<Tooltip
|
||||
id="brand-tooltip"
|
||||
@@ -421,40 +434,44 @@ export function Menu({
|
||||
<span>{brand.text}</span>
|
||||
</StyledBrandText>
|
||||
)}
|
||||
<StyledMainNav
|
||||
mode={isMd ? 'horizontal' : 'inline'}
|
||||
data-test="navbar-top"
|
||||
className="main-nav"
|
||||
selectedKeys={activeTabs}
|
||||
disabledOverflow
|
||||
items={menu.map(item => {
|
||||
const props = {
|
||||
...item,
|
||||
label: t(item.label),
|
||||
isFrontendRoute: isFrontendRoute(item.url),
|
||||
childs: item.childs?.map(c => {
|
||||
if (typeof c === 'string') {
|
||||
return c;
|
||||
}
|
||||
{/* Consumption mode: hide nav items on mobile (drawer holds them) */}
|
||||
{!isMobile && (
|
||||
<StyledMainNav
|
||||
mode={isMd ? 'horizontal' : 'inline'}
|
||||
data-test="navbar-top"
|
||||
className="main-nav"
|
||||
selectedKeys={activeTabs}
|
||||
disabledOverflow
|
||||
items={menu.map(item => {
|
||||
const props = {
|
||||
...item,
|
||||
label: t(item.label),
|
||||
isFrontendRoute: isFrontendRoute(item.url),
|
||||
childs: item.childs?.map(c => {
|
||||
if (typeof c === 'string') {
|
||||
return c;
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
isFrontendRoute: isFrontendRoute(c.url),
|
||||
};
|
||||
}),
|
||||
};
|
||||
return {
|
||||
...c,
|
||||
isFrontendRoute: isFrontendRoute(c.url),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return buildMenuItem(props);
|
||||
})}
|
||||
/>
|
||||
return buildMenuItem(props);
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</StyledCol>
|
||||
<Col md={8} xs={24}>
|
||||
<Col md={8} xs={isMobile ? 4 : 24}>
|
||||
<RightMenu
|
||||
align={isMd ? 'flex-end' : 'flex-start'}
|
||||
align={isMd || isMobile ? 'flex-end' : 'flex-start'}
|
||||
settings={settings}
|
||||
navbarRight={navbarRight}
|
||||
isFrontendRoute={isFrontendRoute}
|
||||
environmentTag={environmentTag}
|
||||
menu={menu}
|
||||
/>
|
||||
</Col>
|
||||
</StyledRow>
|
||||
|
||||
@@ -50,6 +50,15 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile menu rendering)
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('src/features/databases/DatabaseModal', () => {
|
||||
const DatabaseModal = () => <span />;
|
||||
DatabaseModal.displayName = 'DatabaseModal';
|
||||
|
||||
@@ -51,9 +51,16 @@ import {
|
||||
Icons,
|
||||
Typography,
|
||||
TelemetryPixel,
|
||||
Drawer,
|
||||
Button,
|
||||
} from '@superset-ui/core/components';
|
||||
import type { ItemType, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils';
|
||||
import {
|
||||
ensureAppRoot,
|
||||
navigateTo,
|
||||
stripAppRoot,
|
||||
} from 'src/utils/navigationUtils';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
@@ -119,6 +126,7 @@ const RightMenu = ({
|
||||
navbarRight,
|
||||
isFrontendRoute,
|
||||
environmentTag,
|
||||
menu,
|
||||
setQuery,
|
||||
}: RightMenuProps & {
|
||||
setQuery: ({
|
||||
@@ -130,6 +138,8 @@ const RightMenu = ({
|
||||
}) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const user = useSelector<any, UserWithPermissionsAndRoles>(
|
||||
state => state.user,
|
||||
);
|
||||
@@ -644,6 +654,64 @@ const RightMenu = ({
|
||||
handleLogout,
|
||||
]);
|
||||
|
||||
// Build mobile menu items - consumption only (no create/admin actions)
|
||||
const mobileMenuItems = useMemo(() => {
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
// Add Dashboards link at top (from main menu)
|
||||
// Match on the FAB-internal `name`, which is stable across locales
|
||||
// (`label` is translated and would break in non-English deployments)
|
||||
const dashboardsMenu = menu?.find(item => item.name === 'Dashboards');
|
||||
if (dashboardsMenu) {
|
||||
const dashboardUrl = dashboardsMenu.url || '/dashboard/list/';
|
||||
items.push({
|
||||
key: 'dashboards',
|
||||
label: isFrontendRoute(dashboardUrl) ? (
|
||||
<Link to={dashboardUrl}>{t('Dashboards')}</Link>
|
||||
) : (
|
||||
<Typography.Link href={dashboardUrl}>
|
||||
{t('Dashboards')}
|
||||
</Typography.Link>
|
||||
),
|
||||
icon: <Icons.DashboardOutlined />,
|
||||
});
|
||||
}
|
||||
|
||||
// Add theme menu (flatten children directly)
|
||||
menuItems.forEach(item => {
|
||||
if (!item || !('key' in item)) return;
|
||||
|
||||
// Only include theme-sub-menu and language picker
|
||||
if (item.key === 'theme-sub-menu' || item.key === 'language-picker') {
|
||||
items.push({ type: 'divider', key: `divider-before-${item.key}` });
|
||||
|
||||
if ('children' in item && item.children) {
|
||||
// Theme menu already has a nested group, so just add its children directly
|
||||
item.children.forEach(child => {
|
||||
items.push(child);
|
||||
});
|
||||
} else {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract user-related items from settings
|
||||
if (item.key === 'settings' && 'children' in item && item.children) {
|
||||
item.children.forEach(child => {
|
||||
if (!child || !('key' in child)) return;
|
||||
|
||||
// Only include user-section and about-section
|
||||
if (child.key === 'user-section' || child.key === 'about-section') {
|
||||
items.push({ type: 'divider', key: `divider-before-${child.key}` });
|
||||
items.push(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [menu, menuItems, isFrontendRoute]);
|
||||
|
||||
return (
|
||||
<StyledDiv align={align}>
|
||||
{canDatabase && (
|
||||
@@ -704,48 +772,98 @@ const RightMenu = ({
|
||||
</Tag>
|
||||
);
|
||||
})()}
|
||||
<Menu
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
border-bottom: none !important;
|
||||
|
||||
/* Remove the underline from menu items */
|
||||
.ant-menu-item:after,
|
||||
.ant-menu-submenu:after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
.submenu-with-caret {
|
||||
{/* Mobile: hamburger menu with drawer */}
|
||||
{isMobile && (
|
||||
<>
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
aria-label={t('Menu')}
|
||||
>
|
||||
<Icons.MenuOutlined iconSize="l" />
|
||||
</Button>
|
||||
<Drawer
|
||||
title={null}
|
||||
placement="right"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
width={280}
|
||||
styles={{
|
||||
header: { display: 'none' },
|
||||
body: { padding: 0 },
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectable={false}
|
||||
onClick={info => {
|
||||
handleMenuSelection(info);
|
||||
// The reused desktop items navigate via anchors that only
|
||||
// span their label text, but the drawer's tap target is the
|
||||
// full menu row — navigate explicitly so row taps work.
|
||||
if (info.key === 'info' && navbarRight.user_info_url) {
|
||||
navigateTo(navbarRight.user_info_url);
|
||||
return;
|
||||
}
|
||||
if (info.key === 'logout' && navbarRight.user_logout_url) {
|
||||
navigateTo(navbarRight.user_logout_url);
|
||||
return;
|
||||
}
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
items={mobileMenuItems}
|
||||
css={css`
|
||||
border-inline-end: none !important;
|
||||
`}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
)}
|
||||
{/* Desktop: horizontal menu */}
|
||||
{!isMobile && (
|
||||
<Menu
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
.ant-menu-submenu-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
flex-direction: row-reverse;
|
||||
border-bottom: none !important;
|
||||
|
||||
/* Remove the underline from menu items */
|
||||
.ant-menu-item:after,
|
||||
.ant-menu-submenu:after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
.submenu-with-caret {
|
||||
height: 100%;
|
||||
}
|
||||
&.ant-menu-submenu::after {
|
||||
inset-inline: ${theme.sizeUnit}px;
|
||||
}
|
||||
&.ant-menu-submenu:hover,
|
||||
&.ant-menu-submenu-active {
|
||||
.ant-menu-title-content {
|
||||
color: ${theme.colorPrimary};
|
||||
padding: 0;
|
||||
.ant-menu-submenu-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
flex-direction: row-reverse;
|
||||
height: 100%;
|
||||
}
|
||||
&.ant-menu-submenu::after {
|
||||
inset-inline: ${theme.sizeUnit}px;
|
||||
}
|
||||
&.ant-menu-submenu:hover,
|
||||
&.ant-menu-submenu-active {
|
||||
.ant-menu-title-content {
|
||||
color: ${theme.colorPrimary};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
selectable={false}
|
||||
mode="horizontal"
|
||||
onClick={handleMenuSelection}
|
||||
onOpenChange={onMenuOpen}
|
||||
disabledOverflow
|
||||
items={menuItems}
|
||||
/>
|
||||
`}
|
||||
selectable={false}
|
||||
mode="horizontal"
|
||||
onClick={handleMenuSelection}
|
||||
onOpenChange={onMenuOpen}
|
||||
disabledOverflow
|
||||
items={menuItems}
|
||||
/>
|
||||
)}
|
||||
{navbarRight.documentation_url && (
|
||||
<>
|
||||
<StyledAnchor
|
||||
|
||||
@@ -127,3 +127,35 @@ test('should render the buttons', async () => {
|
||||
userEvent.click(testButton);
|
||||
expect(mockFunc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Mobile support tests
|
||||
test('should render leftIcon when provided', async () => {
|
||||
setup({
|
||||
leftIcon: (
|
||||
<button type="button" data-test="left-icon-button">
|
||||
Search
|
||||
</button>
|
||||
),
|
||||
});
|
||||
expect(await screen.findByTestId('left-icon-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render rightIcon when provided', async () => {
|
||||
setup({
|
||||
rightIcon: (
|
||||
<button type="button" data-test="right-icon-button">
|
||||
Menu
|
||||
</button>
|
||||
),
|
||||
});
|
||||
expect(await screen.findByTestId('right-icon-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render both leftIcon and rightIcon together', async () => {
|
||||
setup({
|
||||
leftIcon: <span data-test="mobile-left">Left</span>,
|
||||
rightIcon: <span data-test="mobile-right">Right</span>,
|
||||
});
|
||||
expect(await screen.findByTestId('mobile-left')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('mobile-right')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { MenuObjectProps } from 'src/types/bootstrapTypes';
|
||||
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
|
||||
const StyledHeader = styled.div<{ backgroundColor?: string }>`
|
||||
@@ -109,13 +110,44 @@ const StyledHeader = styled.div<{ backgroundColor?: string }>`
|
||||
.btn-link {
|
||||
padding: 10px 0;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.header,
|
||||
.nav-right {
|
||||
position: relative;
|
||||
margin-left: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
}
|
||||
}
|
||||
${({ theme }) =>
|
||||
isMobileConsumptionEnabled()
|
||||
? css`
|
||||
@media (max-width: ${theme.screenSMMax}px) {
|
||||
.header {
|
||||
position: relative;
|
||||
margin-left: 0;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Consumption mode: hide all buttons on mobile */
|
||||
.nav-right,
|
||||
.nav-right-collapse {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Compact horizontal tabs on mobile (segmented-control style) */
|
||||
.menu > .ant-menu {
|
||||
padding-left: 0;
|
||||
|
||||
.ant-menu-item {
|
||||
padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px;
|
||||
margin-right: ${theme.sizeUnit / 2}px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
: css`
|
||||
@media (max-width: ${theme.screenSMMax}px) {
|
||||
.header,
|
||||
.nav-right {
|
||||
position: relative;
|
||||
margin-left: ${theme.sizeUnit * 2}px;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const styledDisabled = (theme: SupersetTheme) => css`
|
||||
@@ -165,6 +197,10 @@ export interface SubMenuProps {
|
||||
dropDownLinks?: Array<MenuObjectProps>;
|
||||
backgroundColor?: string;
|
||||
children?: ReactNode;
|
||||
/** Left icon for mobile - shown before the header */
|
||||
leftIcon?: ReactNode;
|
||||
/** Right icon for mobile - shown after the header */
|
||||
rightIcon?: ReactNode;
|
||||
}
|
||||
|
||||
const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
|
||||
@@ -186,8 +222,16 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
|
||||
|
||||
function handleResize() {
|
||||
if (!isMounted) return;
|
||||
if (window.innerWidth <= 767) setMenu('inline');
|
||||
else setMenu('horizontal');
|
||||
// In consumption mode the tabs stay horizontal on mobile (the CSS
|
||||
// renders them compact); otherwise fall back to the inline layout.
|
||||
if (
|
||||
!isMobileConsumptionEnabled() &&
|
||||
window.innerWidth <= theme.screenSMMax
|
||||
) {
|
||||
setMenu('inline');
|
||||
} else {
|
||||
setMenu('horizontal');
|
||||
}
|
||||
|
||||
if (
|
||||
props.buttons &&
|
||||
@@ -211,12 +255,14 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
|
||||
resize.cancel();
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [props.buttons]);
|
||||
}, [props.buttons, theme.screenSMMax]);
|
||||
|
||||
return (
|
||||
<StyledHeader backgroundColor={props.backgroundColor}>
|
||||
<Row className="menu" role="navigation" aria-label={t('Page navigation')}>
|
||||
{props.leftIcon}
|
||||
{props.name && <div className="header">{props.name}</div>}
|
||||
{props.rightIcon}
|
||||
<Menu
|
||||
mode={showMenu}
|
||||
disabledOverflow
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface RightMenuProps {
|
||||
color: string;
|
||||
};
|
||||
children?: ReactNode;
|
||||
menu?: MenuObjectProps[];
|
||||
}
|
||||
|
||||
export enum GlobalMenuDataOptions {
|
||||
|
||||
71
superset-frontend/src/hooks/useIsMobile.ts
Normal file
71
superset-frontend/src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
|
||||
// Matches antd's screenSMMax token; used only when no theme is in scope.
|
||||
const FALLBACK_MOBILE_MAX_WIDTH = 767;
|
||||
|
||||
/**
|
||||
* Whether the mobile consumption-only experience is enabled for this
|
||||
* deployment. Non-hook variant for use inside styled-component
|
||||
* interpolations; prefer `useIsMobile` in components.
|
||||
*/
|
||||
export function isMobileConsumptionEnabled(): boolean {
|
||||
return isFeatureEnabled(FeatureFlag.MobileConsumptionMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when MOBILE_CONSUMPTION_MODE is enabled AND the viewport is
|
||||
* at or below the theme's `screenSMMax` breakpoint. All mobile-specific
|
||||
* behavior (route guarding, consumption-only chrome, drawer navigation)
|
||||
* should key off this hook so the flag remains a single kill switch.
|
||||
*
|
||||
* The matchMedia subscription is only installed when the flag is on, and
|
||||
* state only changes when the match flips, so with the flag off (or on
|
||||
* desktop) this hook never causes a re-render — consumers are inert.
|
||||
*
|
||||
* The initial value is always false (desktop), so the first paint never
|
||||
* takes the mobile branch by accident.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
const enabled = isMobileConsumptionEnabled();
|
||||
const theme = useTheme();
|
||||
const maxWidth = theme?.screenSMMax ?? FALLBACK_MOBILE_MAX_WIDTH;
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const mediaQuery = window.matchMedia(`(max-width: ${maxWidth}px)`);
|
||||
const update = () => setIsSmallScreen(mediaQuery.matches);
|
||||
update();
|
||||
// Safari < 14 lacks addEventListener on MediaQueryList
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', update);
|
||||
return () => mediaQuery.removeEventListener('change', update);
|
||||
}
|
||||
mediaQuery.addListener(update);
|
||||
return () => mediaQuery.removeListener(update);
|
||||
}, [enabled, maxWidth]);
|
||||
|
||||
return enabled && isSmallScreen;
|
||||
}
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
SupersetClient,
|
||||
handleKeyboardActivation,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -39,7 +39,9 @@ import Subject from 'src/types/Subject';
|
||||
import { SUBJECT_OPTION_FILTER_PROPS } from 'src/features/subjects/SubjectSelectLabel';
|
||||
import { SubjectPile } from 'src/features/subjects/SubjectPile';
|
||||
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import {
|
||||
Button,
|
||||
CertifiedBadge,
|
||||
ConfirmStatusChange,
|
||||
DeleteModal,
|
||||
@@ -174,6 +176,9 @@ const DASHBOARD_COLUMNS_TO_FETCH = [
|
||||
|
||||
function DashboardList(props: DashboardListProps) {
|
||||
const { addDangerToast, addSuccessToast, user } = props;
|
||||
const isNotMobile = !useIsMobile();
|
||||
const theme = useTheme();
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
|
||||
state => state.user,
|
||||
);
|
||||
@@ -833,7 +838,25 @@ function DashboardList(props: DashboardListProps) {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SubMenu name={t('Dashboards')} buttons={subMenuButtons} />
|
||||
<SubMenu
|
||||
name={t('Dashboards')}
|
||||
buttons={subMenuButtons}
|
||||
leftIcon={
|
||||
!isNotMobile ? (
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
onClick={() => setMobileFiltersOpen(true)}
|
||||
aria-label={t('Search')}
|
||||
css={css`
|
||||
padding: 0;
|
||||
margin-right: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
>
|
||||
<Icons.SearchOutlined iconSize="l" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={t(
|
||||
@@ -922,8 +945,14 @@ function DashboardList(props: DashboardListProps) {
|
||||
? 'card'
|
||||
: 'table'
|
||||
}
|
||||
forceViewMode={!isNotMobile ? 'card' : undefined}
|
||||
enableBulkTag={enableBulkTag}
|
||||
bulkTagResourceName="dashboard"
|
||||
mobileFiltersOpen={mobileFiltersOpen}
|
||||
setMobileFiltersOpen={
|
||||
!isNotMobile ? setMobileFiltersOpen : undefined
|
||||
}
|
||||
mobileFiltersDrawerTitle={t('Search Dashboards')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
179
superset-frontend/src/pages/Home/Home.mobile.test.tsx
Normal file
179
superset-frontend/src/pages/Home/Home.mobile.test.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mobile-specific tests for the Home/Welcome page.
|
||||
*
|
||||
* These tests verify that certain desktop-only sections are hidden
|
||||
* on mobile viewports.
|
||||
*/
|
||||
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import Welcome from 'src/pages/Home';
|
||||
|
||||
import { mockMobileMatchMedia } from 'spec/helpers/mobileTestUtils';
|
||||
|
||||
// Enable only the mobile consumption mode flag so the mobile branch renders
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(
|
||||
(flag: string) => flag === 'MOBILE_CONSUMPTION_MODE',
|
||||
),
|
||||
}));
|
||||
|
||||
// Simulate a mobile viewport for the useIsMobile hook
|
||||
mockMobileMatchMedia();
|
||||
|
||||
// API mocks
|
||||
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
|
||||
const chartInfoEndpoint = 'glob:*/api/v1/chart/_info?*';
|
||||
const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status?*';
|
||||
const dashboardsEndpoint = 'glob:*/api/v1/dashboard/?*';
|
||||
const dashboardInfoEndpoint = 'glob:*/api/v1/dashboard/_info?*';
|
||||
const dashboardFavoriteStatusEndpoint =
|
||||
'glob:*/api/v1/dashboard/favorite_status/?*';
|
||||
const savedQueryEndpoint = 'glob:*/api/v1/saved_query/?*';
|
||||
const savedQueryInfoEndpoint = 'glob:*/api/v1/saved_query/_info?*';
|
||||
const recentActivityEndpoint = 'glob:*/api/v1/log/recent_activity/*';
|
||||
|
||||
fetchMock.get(chartsEndpoint, {
|
||||
result: [
|
||||
{
|
||||
slice_name: 'ChartyChart',
|
||||
changed_on_utc: '24 Feb 2014 10:13:14',
|
||||
url: '/fakeUrl/explore',
|
||||
id: '4',
|
||||
table: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fetchMock.get(dashboardsEndpoint, {
|
||||
result: [
|
||||
{
|
||||
dashboard_title: 'Dashboard_Test',
|
||||
changed_on_utc: '24 Feb 2014 10:13:14',
|
||||
url: '/fakeUrl/dashboard',
|
||||
id: '3',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fetchMock.get(savedQueryEndpoint, {
|
||||
result: [],
|
||||
});
|
||||
|
||||
fetchMock.get(recentActivityEndpoint, {
|
||||
result: [
|
||||
{
|
||||
action: 'dashboard',
|
||||
item_title: "World Bank's Data",
|
||||
item_type: 'dashboard',
|
||||
item_url: '/superset/dashboard/world_health/',
|
||||
time: 1741644942130.566,
|
||||
time_delta_humanized: 'a day ago',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fetchMock.get(chartInfoEndpoint, { permissions: [] });
|
||||
fetchMock.get(chartFavoriteStatusEndpoint, { result: [] });
|
||||
fetchMock.get(dashboardInfoEndpoint, { permissions: [] });
|
||||
fetchMock.get(dashboardFavoriteStatusEndpoint, { result: [] });
|
||||
fetchMock.get(savedQueryInfoEndpoint, { permissions: [] });
|
||||
|
||||
const mockedProps = {
|
||||
user: {
|
||||
username: 'alpha',
|
||||
firstName: 'alpha',
|
||||
lastName: 'alpha',
|
||||
createdOn: '2016-11-11T12:34:17',
|
||||
userId: 5,
|
||||
email: 'alpha@alpha.com',
|
||||
isActive: true,
|
||||
isAnonymous: false,
|
||||
permissions: {},
|
||||
roles: {
|
||||
sql_lab: [['can_read', 'SavedQuery']],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderWelcome = (props = mockedProps) =>
|
||||
waitFor(() => {
|
||||
render(<Welcome {...props} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.clearHistory();
|
||||
});
|
||||
|
||||
test('Mobile view - renders Dashboards panel', async () => {
|
||||
await renderWelcome();
|
||||
expect(await screen.findByText('Dashboards')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - renders Recents panel', async () => {
|
||||
await renderWelcome();
|
||||
expect(await screen.findByText('Recents')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - does NOT render Charts panel', async () => {
|
||||
await renderWelcome();
|
||||
|
||||
// Wait for Dashboards to ensure the component has rendered
|
||||
await screen.findByText('Dashboards');
|
||||
|
||||
// Charts panel should NOT be present on mobile
|
||||
// Look specifically for the Charts collapse panel header
|
||||
const chartsPanel = screen.queryByRole('button', { name: /Charts/i });
|
||||
expect(chartsPanel).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - does NOT render Saved queries panel', async () => {
|
||||
await renderWelcome();
|
||||
|
||||
// Wait for Dashboards to ensure the component has rendered
|
||||
await screen.findByText('Dashboards');
|
||||
|
||||
// Saved queries panel should NOT be present on mobile
|
||||
const savedQueriesPanel = screen.queryByRole('button', {
|
||||
name: /Saved queries/i,
|
||||
});
|
||||
expect(savedQueriesPanel).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - only shows 2 panels (Recents and Dashboards)', async () => {
|
||||
await renderWelcome();
|
||||
|
||||
// Wait for content to load
|
||||
await screen.findByText('Dashboards');
|
||||
|
||||
// Should only have Recents and Dashboards panels visible
|
||||
// Charts and Saved queries are hidden on mobile
|
||||
const recentsPanel = screen.queryByText('Recents');
|
||||
const dashboardsPanel = screen.queryByText('Dashboards');
|
||||
|
||||
expect(recentsPanel).toBeInTheDocument();
|
||||
expect(dashboardsPanel).toBeInTheDocument();
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { styled } from '@apache-superset/core/theme';
|
||||
import rison from 'rison';
|
||||
import { Collapse, ListViewCard } from '@superset-ui/core/components';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import { reject } from 'lodash-es';
|
||||
import {
|
||||
dangerouslyGetItemDoNotUse,
|
||||
@@ -147,6 +148,7 @@ export const LoadingCards = ({ cover }: LoadingProps) => (
|
||||
);
|
||||
|
||||
function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
const isNotMobile = !useIsMobile();
|
||||
const canReadSavedQueries = userHasPermission(user, 'SavedQuery', 'can_read');
|
||||
const userid = user.userId;
|
||||
const id = userid!.toString(); // confident that user is not a guest user
|
||||
@@ -397,24 +399,29 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'charts',
|
||||
label: t('Charts'),
|
||||
children:
|
||||
!chartData || isRecentActivityLoading ? (
|
||||
<LoadingCards cover={checked} />
|
||||
) : (
|
||||
<ChartTable
|
||||
showThumbnails={checked}
|
||||
user={user}
|
||||
mine={chartData}
|
||||
otherTabData={activityData?.[TableTab.Other]}
|
||||
otherTabFilters={otherTabFilters}
|
||||
otherTabTitle={otherTabTitle}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(canReadSavedQueries
|
||||
// Hide Charts and Saved queries on mobile - consumption-only mode
|
||||
...(isNotMobile
|
||||
? [
|
||||
{
|
||||
key: 'charts',
|
||||
label: t('Charts'),
|
||||
children:
|
||||
!chartData || isRecentActivityLoading ? (
|
||||
<LoadingCards cover={checked} />
|
||||
) : (
|
||||
<ChartTable
|
||||
showThumbnails={checked}
|
||||
user={user}
|
||||
mine={chartData}
|
||||
otherTabData={activityData?.[TableTab.Other]}
|
||||
otherTabFilters={otherTabFilters}
|
||||
otherTabTitle={otherTabTitle}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isNotMobile && canReadSavedQueries
|
||||
? [
|
||||
{
|
||||
key: 'saved-queries',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import MobileUnsupported from './index';
|
||||
|
||||
// Mock useHistory
|
||||
const mockPush = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderComponent = () =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/chart/list/']}>
|
||||
<MobileUnsupported />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
test('renders the page title', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByText("This view isn't available on mobile"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the description text', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Some features require a larger screen. Try viewing dashboards for the best mobile experience.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the View Dashboards button', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'View Dashboards' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the Go to Welcome Page button', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Go to Welcome Page' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render a Continue anyway bypass', () => {
|
||||
renderComponent();
|
||||
expect(screen.queryByText(/Continue anyway/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('View Dashboards button navigates to dashboard list', async () => {
|
||||
renderComponent();
|
||||
|
||||
const button = screen.getByRole('button', { name: 'View Dashboards' });
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/dashboard/list/');
|
||||
});
|
||||
|
||||
test('Go to Welcome Page button navigates to welcome page', async () => {
|
||||
renderComponent();
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Go to Welcome Page' });
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/welcome/');
|
||||
});
|
||||
143
superset-frontend/src/pages/MobileUnsupported/index.tsx
Normal file
143
superset-frontend/src/pages/MobileUnsupported/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Button } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
|
||||
/**
|
||||
* A mobile-friendly page shown when users try to access
|
||||
* features that aren't supported on mobile devices. Growing the window
|
||||
* past the mobile breakpoint unblocks the route automatically (useIsMobile
|
||||
* subscribes to the breakpoint), so no manual bypass is offered.
|
||||
*/
|
||||
function MobileUnsupported() {
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
|
||||
const handleViewDashboards = useCallback(() => {
|
||||
history.push('/dashboard/list/');
|
||||
}, [history]);
|
||||
|
||||
const handleGoHome = useCallback(() => {
|
||||
history.push('/welcome/');
|
||||
}, [history]);
|
||||
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 60px);
|
||||
padding: ${theme.sizeUnit * 6}px;
|
||||
text-align: center;
|
||||
background: ${theme.colorBgContainer};
|
||||
`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
css={css`
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: ${theme.colorBgLayout};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: ${theme.sizeUnit * 6}px;
|
||||
`}
|
||||
>
|
||||
<Icons.DesktopOutlined
|
||||
iconSize="xxl"
|
||||
iconColor={theme.colorTextSecondary}
|
||||
css={css`
|
||||
font-size: 48px;
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1
|
||||
css={css`
|
||||
font-size: ${theme.fontSizeXL}px;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${theme.colorText};
|
||||
margin: 0 0 ${theme.sizeUnit * 2}px 0;
|
||||
`}
|
||||
>
|
||||
{t("This view isn't available on mobile")}
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
css={css`
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
color: ${theme.colorTextSecondary};
|
||||
margin: 0 0 ${theme.sizeUnit * 8}px 0;
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
`}
|
||||
>
|
||||
{t(
|
||||
'Some features require a larger screen. Try viewing dashboards for the best mobile experience.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Primary action */}
|
||||
<div>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
onClick={handleViewDashboards}
|
||||
css={css`
|
||||
width: 280px;
|
||||
height: 48px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{t('View Dashboards')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Secondary action */}
|
||||
<div
|
||||
css={css`
|
||||
margin-top: ${theme.sizeUnit * 3}px;
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={handleGoHome}
|
||||
css={css`
|
||||
width: 280px;
|
||||
height: 48px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{t('Go to Welcome Page')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileUnsupported;
|
||||
@@ -35,6 +35,7 @@ import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Flex, Layout, Loading } from '@superset-ui/core/components';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { ErrorBoundary } from 'src/components';
|
||||
import MobileRouteGuard from 'src/components/MobileRouteGuard';
|
||||
import MenuWrapper from 'src/features/home/Menu';
|
||||
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
|
||||
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
|
||||
@@ -97,19 +98,29 @@ const RouteSwitch = () => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Switch>
|
||||
{routes.map(({ path, Component, props = {}, Fallback = Loading }) => (
|
||||
<Route path={path} key={path}>
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<ErrorBoundary
|
||||
css={css`
|
||||
margin: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
<Component user={bootstrapData.user} {...props} />
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</Route>
|
||||
))}
|
||||
{routes.map(
|
||||
({
|
||||
path,
|
||||
Component,
|
||||
props = {},
|
||||
Fallback = Loading,
|
||||
mobileSupported,
|
||||
}) => (
|
||||
<Route path={path} key={path}>
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<MobileRouteGuard mobileSupported={mobileSupported}>
|
||||
<ErrorBoundary
|
||||
css={css`
|
||||
margin: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
<Component user={bootstrapData.user} {...props} />
|
||||
</ErrorBoundary>
|
||||
</MobileRouteGuard>
|
||||
</Suspense>
|
||||
</Route>
|
||||
),
|
||||
)}
|
||||
<Redirect from="/" to="/welcome/" exact />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -194,18 +194,36 @@ type Routes = {
|
||||
Component: ComponentType<any>;
|
||||
Fallback?: ComponentType<any>;
|
||||
props?: ComponentProps<any>;
|
||||
/**
|
||||
* Marks a route as usable in the mobile consumption-only experience.
|
||||
* Routes without this flag show the MobileUnsupported screen on small
|
||||
* screens when MOBILE_CONSUMPTION_MODE is enabled.
|
||||
*/
|
||||
mobileSupported?: boolean;
|
||||
}[];
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: RoutePaths.REDIRECT, Component: RedirectWarning },
|
||||
{ path: RoutePaths.LOGIN, Component: Login },
|
||||
{ path: RoutePaths.REGISTER_ACTIVATION, Component: Register },
|
||||
{ path: RoutePaths.REGISTER, Component: Register },
|
||||
{ path: RoutePaths.LOGOUT, Component: Login },
|
||||
{ path: RoutePaths.HOME, Component: Home },
|
||||
{
|
||||
path: RoutePaths.REDIRECT,
|
||||
Component: RedirectWarning,
|
||||
mobileSupported: true,
|
||||
},
|
||||
{ path: RoutePaths.LOGIN, Component: Login, mobileSupported: true },
|
||||
{
|
||||
path: RoutePaths.REGISTER_ACTIVATION,
|
||||
Component: Register,
|
||||
mobileSupported: true,
|
||||
},
|
||||
{ path: RoutePaths.REGISTER, Component: Register, mobileSupported: true },
|
||||
{ path: RoutePaths.LOGOUT, Component: Login, mobileSupported: true },
|
||||
{ path: RoutePaths.HOME, Component: Home, mobileSupported: true },
|
||||
{ path: RoutePaths.FILE_HANDLER, Component: FileHandler },
|
||||
{ path: RoutePaths.DASHBOARD_LIST, Component: DashboardList },
|
||||
{ path: RoutePaths.DASHBOARD, Component: Dashboard },
|
||||
{
|
||||
path: RoutePaths.DASHBOARD_LIST,
|
||||
Component: DashboardList,
|
||||
mobileSupported: true,
|
||||
},
|
||||
{ path: RoutePaths.DASHBOARD, Component: Dashboard, mobileSupported: true },
|
||||
{ path: RoutePaths.CHART_ADD, Component: ChartCreation },
|
||||
{ path: RoutePaths.CHART_LIST, Component: ChartList },
|
||||
{ path: RoutePaths.DATASET_LIST, Component: DatasetList },
|
||||
@@ -235,7 +253,7 @@ export const routes: Routes = [
|
||||
{ path: RoutePaths.ROW_LEVEL_SECURITY, Component: RowLevelSecurityList },
|
||||
{ path: RoutePaths.TASKS, Component: TaskList },
|
||||
{ path: RoutePaths.SQLLAB, Component: SqlLab },
|
||||
{ path: RoutePaths.USER_INFO, Component: UserInfo },
|
||||
{ path: RoutePaths.USER_INFO, Component: UserInfo, mobileSupported: true },
|
||||
{ path: RoutePaths.ACTION_LOG, Component: ActionLogList },
|
||||
{ path: RoutePaths.REGISTRATIONS, Component: UserRegistrations },
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
</title>
|
||||
|
||||
{% block head_meta %}
|
||||
{% if is_feature_enabled is defined and is_feature_enabled('MOBILE_CONSUMPTION_MODE') %}
|
||||
{# Without this, mobile browsers lay the page out at the ~980px legacy
|
||||
viewport and the mobile consumption mode's breakpoints never match.
|
||||
Only emitted when the flag is on so desktop-only deployments keep
|
||||
the shrink-to-fit rendering on phones. #}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{% endif %}
|
||||
{# Manifest is served dynamically by PwaManifestView so APPLICATION_ROOT
|
||||
and STATIC_ASSETS_PREFIX resolve at request time. Do NOT use
|
||||
`assets_prefix` here — that may point to a CDN host with no
|
||||
|
||||
Reference in New Issue
Block a user