mirror of
https://github.com/apache/superset.git
synced 2026-07-16 19:55:39 +00:00
Compare commits
2 Commits
msyavuz/ch
...
mobile-das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9da3908bec | ||
|
|
76c247513e |
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,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ReactNode, ReactElement, memo } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, SupersetTheme, useTheme } from '@apache-superset/core/theme';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { FeatureFlag, isFeatureEnabled } from '../../utils/featureFlags';
|
||||
import type { DropdownProps } from '../Dropdown/types';
|
||||
import type { TooltipPlacement } from '../Tooltip/types';
|
||||
import type { CertifiedBadgeProps } from '../CertifiedBadge/types';
|
||||
@@ -82,6 +83,18 @@ const headerStyles = (theme: SupersetTheme) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Mobile consumption mode: center the title between left/right panels */
|
||||
${isFeatureEnabled(FeatureFlag.MobileConsumptionMode) &&
|
||||
css`
|
||||
@media (max-width: ${theme.screenSMMax}px) {
|
||||
.title-panel {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const buttonsStyles = (theme: SupersetTheme) => css`
|
||||
@@ -109,6 +122,7 @@ export type PageHeaderWithActionsProps = {
|
||||
showFaveStar: boolean;
|
||||
showMenuDropdown?: boolean;
|
||||
faveStarProps: FaveStarProps;
|
||||
leftPanelItems?: ReactNode;
|
||||
titlePanelAdditionalItems: ReactNode;
|
||||
rightPanelAdditionalItems: ReactNode;
|
||||
additionalActionsMenu: ReactElement;
|
||||
@@ -126,6 +140,7 @@ export const PageHeaderWithActions = memo(
|
||||
certificatiedBadgeProps,
|
||||
showFaveStar,
|
||||
faveStarProps,
|
||||
leftPanelItems,
|
||||
titlePanelAdditionalItems,
|
||||
rightPanelAdditionalItems,
|
||||
additionalActionsMenu,
|
||||
@@ -136,6 +151,7 @@ export const PageHeaderWithActions = memo(
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<div css={headerStyles} className="header-with-actions">
|
||||
{leftPanelItems}
|
||||
<div className="title-panel">
|
||||
<DynamicEditableTitle {...editableTitleProps} />
|
||||
{showTitlePanelItems && (
|
||||
|
||||
@@ -59,6 +59,7 @@ export enum FeatureFlag {
|
||||
GranularExportControls = 'GRANULAR_EXPORT_CONTROLS',
|
||||
ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW',
|
||||
Matrixify = 'MATRIXIFY',
|
||||
MobileConsumptionMode = 'MOBILE_CONSUMPTION_MODE',
|
||||
ScheduledQueries = 'SCHEDULED_QUERIES',
|
||||
SemanticLayers = 'SEMANTIC_LAYERS',
|
||||
SqllabBackendPersistence = 'SQLLAB_BACKEND_PERSISTENCE',
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { test, expect, devices } from '@playwright/test';
|
||||
|
||||
// NOTE: These tests exercise the mobile consumption experience and require
|
||||
// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target
|
||||
// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}).
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Mobile dashboard viewing tests verify that dashboards can be viewed
|
||||
* and interacted with on mobile devices.
|
||||
*
|
||||
* These tests assume the World Bank's Health sample dashboard exists.
|
||||
*/
|
||||
|
||||
// Use iPhone 12 viewport for mobile tests
|
||||
const mobileViewport = devices['iPhone 12'];
|
||||
|
||||
test.describe('Mobile Dashboard Viewing', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to dashboard list to find a dashboard
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('dashboard list renders in card view on mobile', async ({ page }) => {
|
||||
// On mobile, dashboard list should show cards, not table
|
||||
// Look for card elements
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
|
||||
// Should have at least one card if dashboards exist
|
||||
// (This test may need adjustment based on test data availability)
|
||||
const cardCount = await cards.count();
|
||||
|
||||
// Either cards are visible, or the empty state is shown
|
||||
if (cardCount > 0) {
|
||||
await expect(cards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
} else {
|
||||
// No dashboards - that's OK for the test environment
|
||||
await expect(
|
||||
page
|
||||
.getByText('No dashboards yet')
|
||||
.or(page.locator('[data-test="listview-table"]')),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile search button appears in dashboard list', async ({ page }) => {
|
||||
// On mobile, the search/filter button should appear in the header
|
||||
const searchButton = page
|
||||
.locator('[aria-label="Search"]')
|
||||
.or(page.locator('[data-test="mobile-search-button"]'));
|
||||
|
||||
// Search button should be visible on mobile
|
||||
await expect(searchButton.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('tapping dashboard card opens the dashboard', async ({ page }) => {
|
||||
// Find a dashboard card
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
// Click the first card
|
||||
await cards.first().click();
|
||||
|
||||
// Should navigate to dashboard view
|
||||
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Dashboard should load (look for dashboard content)
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="dashboard-content-wrapper"]')
|
||||
.or(page.locator('.dashboard')),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Dashboard Interaction', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
// Skip this test suite if no dashboards exist
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
await page.close();
|
||||
|
||||
if (cardCount === 0) {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test('dashboard loads and shows charts on mobile', async ({ page }) => {
|
||||
// Navigate to dashboard list
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click first dashboard
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await cards.first().click();
|
||||
|
||||
// Wait for dashboard to load
|
||||
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Dashboard content should be visible
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="dashboard-content-wrapper"]')
|
||||
.or(page.locator('.dashboard')),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Charts should start loading (look for chart containers)
|
||||
const chartContainers = page
|
||||
.locator('[data-test="chart-container"]')
|
||||
.or(page.locator('.dashboard-chart'));
|
||||
|
||||
// Wait for at least one chart to be visible (with timeout)
|
||||
await expect(chartContainers.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD * 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('dashboard header shows hamburger menu on mobile', async ({ page }) => {
|
||||
// Navigate to dashboard list
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click first dashboard
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await cards.first().click();
|
||||
|
||||
// Wait for dashboard
|
||||
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Look for the hamburger menu / more actions button
|
||||
const menuButton = page
|
||||
.locator('[data-test="actions-trigger"]')
|
||||
.or(page.locator('[aria-label="Menu actions trigger"]'));
|
||||
|
||||
await expect(menuButton.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('refresh dashboard works from mobile menu', async ({ page }) => {
|
||||
// Navigate to dashboard list
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click first dashboard
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await cards.first().click();
|
||||
|
||||
// Wait for dashboard
|
||||
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Open the actions menu
|
||||
const menuButton = page
|
||||
.locator('[data-test="actions-trigger"]')
|
||||
.or(page.locator('[aria-label="Menu actions trigger"]'));
|
||||
|
||||
if ((await menuButton.count()) > 0) {
|
||||
await menuButton.first().click();
|
||||
|
||||
// Look for refresh option
|
||||
const refreshOption = page.getByText('Refresh dashboard');
|
||||
|
||||
if ((await refreshOption.count()) > 0) {
|
||||
await refreshOption.click();
|
||||
|
||||
// Should show success toast or refresh the charts
|
||||
// This is hard to verify without checking network requests
|
||||
// Just verify the menu closes and we're still on the dashboard
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toMatch(/\/dashboard\/(?!list)/);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Filter Drawer', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test('filter button appears on dashboards with filters', async ({ page }) => {
|
||||
// Navigate to dashboard list
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click first dashboard
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await cards.first().click();
|
||||
|
||||
// Wait for dashboard
|
||||
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Give filters time to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check for filter button (only visible if dashboard has filters)
|
||||
const filterButton = page
|
||||
.locator('[data-test="filter-icon"]')
|
||||
.or(
|
||||
page
|
||||
.locator('[aria-label="Filters"]')
|
||||
.or(page.locator('.mobile-filter-button')),
|
||||
);
|
||||
|
||||
const filterCount = await filterButton.count();
|
||||
|
||||
// The test passes whether filters exist or not
|
||||
// If filters exist, button should be visible
|
||||
// If no filters, that's also valid
|
||||
if (filterCount > 0) {
|
||||
await expect(filterButton.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('filter drawer opens when filter button is tapped', async ({ page }) => {
|
||||
// Navigate to dashboard list
|
||||
await page.goto('dashboard/list/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click first dashboard
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await cards.first().click();
|
||||
|
||||
// Wait for dashboard
|
||||
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Give filters time to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check for filter button
|
||||
const filterButton = page
|
||||
.locator('[data-test="filter-icon"]')
|
||||
.or(page.locator('[aria-label="Filters"]'));
|
||||
|
||||
if ((await filterButton.count()) > 0) {
|
||||
await filterButton.first().click();
|
||||
|
||||
// Filter drawer should open
|
||||
const drawer = page
|
||||
.locator('.ant-drawer-open')
|
||||
.or(page.locator('[data-test="filter-bar"]'));
|
||||
|
||||
await expect(drawer.first()).toBeVisible({
|
||||
timeout: TIMEOUT.FORM_LOAD,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { test, expect, devices } from '@playwright/test';
|
||||
|
||||
// NOTE: These tests exercise the mobile consumption experience and require
|
||||
// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target
|
||||
// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}).
|
||||
import { URL } from '../../utils/urls';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Mobile navigation tests verify the MobileRouteGuard behavior
|
||||
* and mobile-specific navigation patterns.
|
||||
*
|
||||
* These tests run with a mobile viewport to trigger mobile-specific behavior.
|
||||
*/
|
||||
|
||||
// Use iPhone 12 viewport for mobile tests
|
||||
const mobileViewport = devices['iPhone 12'];
|
||||
|
||||
test.describe('Mobile Navigation', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear any previous bypass flags
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
sessionStorage.removeItem('mobile-bypass');
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile viewport redirects from chart list to MobileUnsupported page', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to chart list (not mobile-supported)
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Should show the MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Primary action buttons should be visible
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'View Dashboards' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Go to Welcome Page' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('mobile viewport allows access to dashboard list', async ({ page }) => {
|
||||
// Navigate to dashboard list (mobile-supported)
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show dashboard list content (look for dashboard list elements)
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
|
||||
test('mobile viewport allows access to welcome page', async ({ page }) => {
|
||||
// Navigate to welcome page (mobile-supported)
|
||||
await page.goto(URL.WELCOME);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show welcome page content
|
||||
await expect(
|
||||
page.getByText('Recents').or(page.getByText('Dashboards')).first(),
|
||||
).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('View Dashboards button navigates to dashboard list', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to unsupported route
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Wait for MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Click View Dashboards button
|
||||
await page.getByRole('button', { name: 'View Dashboards' }).click();
|
||||
|
||||
// Should navigate to dashboard list
|
||||
await page.waitForURL(url => url.pathname.includes('dashboard/list'), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Dashboard list should be accessible
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
|
||||
test('Go to Welcome Page button navigates to welcome', async ({ page }) => {
|
||||
// Navigate to unsupported route
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Wait for MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Click Go to Welcome Page button
|
||||
await page.getByRole('button', { name: 'Go to Welcome Page' }).click();
|
||||
|
||||
// Should navigate to welcome page
|
||||
await page.waitForURL(url => url.pathname.includes('welcome'), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('Continue anyway bypasses guard for the session', async ({ page }) => {
|
||||
// Navigate to unsupported route
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Wait for MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Click Continue anyway
|
||||
await page.getByText('Continue anyway').click();
|
||||
|
||||
// Should navigate to the original destination
|
||||
await page.waitForURL(url => url.pathname.includes('chart/list'), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Verify bypass is set in sessionStorage
|
||||
const bypassValue = await page.evaluate(() =>
|
||||
sessionStorage.getItem('mobile-bypass'),
|
||||
);
|
||||
expect(bypassValue).toBe('true');
|
||||
|
||||
// Navigate away and back - should still be bypassed
|
||||
await page.goto(URL.WELCOME);
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Should NOT show MobileUnsupported page anymore
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
});
|
||||
|
||||
test('SQL Lab is not accessible on mobile', async ({ page }) => {
|
||||
// Navigate to SQL Lab (not mobile-supported)
|
||||
await page.goto(URL.SQLLAB);
|
||||
|
||||
// Should show the MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Desktop Navigation (control group)', () => {
|
||||
// Use default desktop viewport
|
||||
|
||||
test('desktop viewport allows access to all routes', async ({ page }) => {
|
||||
// Navigate to chart list
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show chart list content
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
});
|
||||
164
superset-frontend/spec/helpers/mobileTestUtils.ts
Normal file
164
superset-frontend/spec/helpers/mobileTestUtils.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mobile testing utilities for Jest tests.
|
||||
*
|
||||
* Note: We mock 'antd' directly rather than '@superset-ui/core/components' because
|
||||
* mocking the latter causes circular dependency issues with ActionButton during
|
||||
* jest.requireActual evaluation. Since Grid is re-exported from antd, mocking
|
||||
* antd at the source works correctly.
|
||||
*/
|
||||
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
|
||||
/**
|
||||
* Standard mobile breakpoint values (below md breakpoint)
|
||||
*/
|
||||
export const mobileBreakpoints = {
|
||||
xs: true,
|
||||
sm: true,
|
||||
md: false,
|
||||
lg: false,
|
||||
xl: false,
|
||||
xxl: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard desktop breakpoint values (at or above md breakpoint)
|
||||
*/
|
||||
export const desktopBreakpoints = {
|
||||
xs: true,
|
||||
sm: true,
|
||||
md: true,
|
||||
lg: true,
|
||||
xl: true,
|
||||
xxl: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock for antd Grid.useBreakpoint that returns mobile breakpoints.
|
||||
* Use this at the top of test files that need to simulate mobile viewport.
|
||||
*
|
||||
* @example
|
||||
* jest.mock('antd', () => mockAntdWithMobileBreakpoint());
|
||||
*/
|
||||
export const mockAntdWithMobileBreakpoint = () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => mobileBreakpoints,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock for antd Grid.useBreakpoint that returns desktop breakpoints.
|
||||
* Use this at the top of test files that need to simulate desktop viewport.
|
||||
*
|
||||
* @example
|
||||
* jest.mock('antd', () => mockAntdWithDesktopBreakpoint());
|
||||
*/
|
||||
export const mockAntdWithDesktopBreakpoint = () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => desktopBreakpoints,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Enables the MOBILE_CONSUMPTION_MODE feature flag on window.featureFlags.
|
||||
* Mobile behavior requires BOTH a small viewport (mock Grid.useBreakpoint)
|
||||
* AND this flag; call this in beforeAll/beforeEach of mobile test suites.
|
||||
* Returns a cleanup function restoring the previous flags.
|
||||
*/
|
||||
export const enableMobileConsumptionFlag = () => {
|
||||
const previous = window.featureFlags;
|
||||
window.featureFlags = {
|
||||
...window.featureFlags,
|
||||
[FeatureFlag.MobileConsumptionMode]: true,
|
||||
};
|
||||
return () => {
|
||||
window.featureFlags = previous;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Common mobile viewport dimensions for reference
|
||||
*/
|
||||
export const mobileViewports = {
|
||||
iPhoneX: { width: 375, height: 812 },
|
||||
iPhoneSE: { width: 375, height: 667 },
|
||||
iPhone12Pro: { width: 390, height: 844 },
|
||||
pixel5: { width: 393, height: 851 },
|
||||
samsungGalaxyS20: { width: 360, height: 800 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Common tablet viewport dimensions for reference
|
||||
*/
|
||||
export const tabletViewports = {
|
||||
iPadMini: { width: 768, height: 1024 },
|
||||
iPadAir: { width: 820, height: 1180 },
|
||||
iPadPro11: { width: 834, height: 1194 },
|
||||
surfacePro7: { width: 912, height: 1368 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to mock sessionStorage for tests
|
||||
*/
|
||||
export const mockSessionStorage = () => {
|
||||
const store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: jest.fn((key: string) => store[key] ?? null),
|
||||
setItem: jest.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: jest.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: jest.fn(() => {
|
||||
Object.keys(store).forEach(key => delete store[key]);
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: jest.fn((index: number) => Object.keys(store)[index] ?? null),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to create a failing sessionStorage mock (simulates privacy mode)
|
||||
*/
|
||||
export const mockSessionStorageFailure = () => ({
|
||||
getItem: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
setItem: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
removeItem: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
clear: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
length: 0,
|
||||
key: jest.fn(() => null),
|
||||
});
|
||||
@@ -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, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div>Card</div>}
|
||||
forceViewMode="card"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// View toggle should not be present when forceViewMode is set
|
||||
expect(screen.queryByLabelText('card-view')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('list-view')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows card view when forceViewMode is card', () => {
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div data-test="test-card">Card Content</div>}
|
||||
forceViewMode="card"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Should render cards, not table rows
|
||||
expect(screen.getAllByTestId('test-card')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('renders mobile filter drawer when mobileFiltersOpen is true', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search Dashboards"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Drawer should be visible with custom title
|
||||
expect(screen.getByText('Search Dashboards')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls setMobileFiltersOpen(false) when drawer is closed', async () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Click the close button on the drawer
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
expect(setMobileFiltersOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('mobile drawer contains FilterControls', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// The drawer should contain the filter controls; select filters render
|
||||
// as popover triggers (aria-haspopup="listbox") in the drawer
|
||||
const drawer = screen.getByRole('dialog');
|
||||
const filterTriggers = drawer.querySelectorAll('[aria-haspopup="listbox"]');
|
||||
expect(filterTriggers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('mobile drawer contains CardSortSelect when in card view with sort options', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...mockedPropsComprehensive}
|
||||
renderCard={() => <div>Card</div>}
|
||||
forceViewMode="card"
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
initialSort={[{ id: 'something' }]}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Sort select should be present (may be multiple - one in drawer, one in header)
|
||||
const sortSelects = screen.getAllByTestId('card-sort-select');
|
||||
expect(sortSelects.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('uses default drawer title when mobileFiltersDrawerTitle not provided', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Default title should be 'Search'
|
||||
expect(screen.getByText('Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render drawer when mobileFiltersOpen is false', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen={false}
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Drawer should not be visible (title not in visible content)
|
||||
// Note: Ant Design drawer might still be in DOM but hidden
|
||||
const drawer = document.querySelector('.ant-drawer-open');
|
||||
expect(drawer).toBeNull();
|
||||
});
|
||||
|
||||
test('does not render mobile drawer without setMobileFiltersOpen prop', () => {
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView {...propsWithoutSort} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// No drawer elements should exist
|
||||
const drawer = document.querySelector('.ant-drawer');
|
||||
expect(drawer).toBeNull();
|
||||
});
|
||||
|
||||
test('forceViewMode table shows table view', () => {
|
||||
const { cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div data-test="card">Card</div>}
|
||||
forceViewMode="table"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Should show table, not cards
|
||||
expect(screen.queryByTestId('card')).not.toBeInTheDocument();
|
||||
// Table should be present
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import BulkTagModal from 'src/features/tags/BulkTagModal';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
Drawer,
|
||||
Icons,
|
||||
EmptyState,
|
||||
Loading,
|
||||
@@ -239,6 +240,30 @@ const EmptyWrapper = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
const MobileFilterDrawerContent = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 4}px;
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
|
||||
/* Make filter inputs stack vertically and full-width */
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Override inline filter styling for vertical layout */
|
||||
.filter-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
.ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ViewModeToggle = ({
|
||||
mode,
|
||||
setMode,
|
||||
@@ -305,6 +330,7 @@ export interface ListViewProps<T extends object = any> {
|
||||
renderCard?: (row: T & { loading: boolean }) => ReactNode;
|
||||
cardSortSelectOptions?: Array<CardSortSelectOption>;
|
||||
defaultViewMode?: ViewModeType;
|
||||
forceViewMode?: ViewModeType;
|
||||
highlightRowId?: number;
|
||||
showThumbnails?: boolean;
|
||||
emptyState?: EmptyStateProps;
|
||||
@@ -320,6 +346,12 @@ export interface ListViewProps<T extends object = any> {
|
||||
expandable?: Record<string, unknown>;
|
||||
/** Content rendered between the filter bar and the table/card body. */
|
||||
headerContent?: ReactNode;
|
||||
/** Whether mobile filters drawer is open (controlled externally) */
|
||||
mobileFiltersOpen?: boolean;
|
||||
/** Callback to set mobile filters drawer open state */
|
||||
setMobileFiltersOpen?: (open: boolean) => void;
|
||||
/** Title for the mobile filters drawer */
|
||||
mobileFiltersDrawerTitle?: string;
|
||||
}
|
||||
|
||||
export function ListView<T extends object = any>({
|
||||
@@ -341,6 +373,7 @@ export function ListView<T extends object = any>({
|
||||
showThumbnails,
|
||||
cardSortSelectOptions,
|
||||
defaultViewMode = 'card',
|
||||
forceViewMode,
|
||||
highlightRowId,
|
||||
emptyState,
|
||||
columnsForWrapText,
|
||||
@@ -351,6 +384,9 @@ export function ListView<T extends object = any>({
|
||||
headerContent,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
mobileFiltersOpen = false,
|
||||
setMobileFiltersOpen,
|
||||
mobileFiltersDrawerTitle,
|
||||
}: ListViewProps<T>) {
|
||||
const {
|
||||
getTableProps,
|
||||
@@ -377,6 +413,7 @@ export function ListView<T extends object = any>({
|
||||
initialFilters: filters,
|
||||
renderCard: Boolean(renderCard),
|
||||
defaultViewMode,
|
||||
forceViewMode,
|
||||
});
|
||||
const allowBulkTagActions = bulkTagResourceName && enableBulkTag;
|
||||
const filterable = Boolean(filters.length);
|
||||
@@ -453,11 +490,15 @@ export function ListView<T extends object = any>({
|
||||
)}
|
||||
<div data-test={className} className={`superset-list-view ${className} `}>
|
||||
<div className="header">
|
||||
{cardViewEnabled && (
|
||||
{cardViewEnabled && !forceViewMode && (
|
||||
<ViewModeToggle mode={viewMode} setMode={setViewMode} />
|
||||
)}
|
||||
<div className="controls" data-test="filters-select">
|
||||
{filterable && (
|
||||
{/* When a mobile drawer callback is provided, filters and sort
|
||||
render inside the drawer instead of inline. Only one
|
||||
FilterControls instance is ever mounted, so filtersRef and
|
||||
filterControlsRef always point at the visible instance. */}
|
||||
{filterable && !setMobileFiltersOpen && (
|
||||
<FilterControls
|
||||
ref={filterControlsRef}
|
||||
filters={filters}
|
||||
@@ -465,13 +506,15 @@ export function ListView<T extends object = any>({
|
||||
updateFilterValue={applyFilterValue}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'card' && cardSortSelectOptions && (
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'card' &&
|
||||
cardSortSelectOptions &&
|
||||
!setMobileFiltersOpen && (
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
{filterable && (
|
||||
<Tooltip
|
||||
title={!hasActiveFilters ? t('No filters applied') : undefined}
|
||||
@@ -659,6 +702,33 @@ export function ListView<T extends object = any>({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile filter drawer */}
|
||||
{filterable && setMobileFiltersOpen && (
|
||||
<Drawer
|
||||
title={mobileFiltersDrawerTitle || t('Search')}
|
||||
placement="left"
|
||||
onClose={() => setMobileFiltersOpen(false)}
|
||||
open={mobileFiltersOpen}
|
||||
width={300}
|
||||
>
|
||||
<MobileFilterDrawerContent>
|
||||
<FilterControls
|
||||
ref={filterControlsRef}
|
||||
filters={filters}
|
||||
internalFilters={internalFilters}
|
||||
updateFilterValue={applyFilterValue}
|
||||
/>
|
||||
{viewMode === 'card' && cardSortSelectOptions && (
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
</MobileFilterDrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</ListViewStyles>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,73 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import MobileRouteGuard from '.';
|
||||
import { MOBILE_BYPASS_STORAGE_KEY } from '../../pages/MobileUnsupported';
|
||||
|
||||
jest.mock('src/hooks/useIsMobile', () => ({
|
||||
useIsMobile: jest.fn(),
|
||||
isMobileConsumptionEnabled: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const mockedUseIsMobile = useIsMobile as jest.MockedFunction<
|
||||
typeof useIsMobile
|
||||
>;
|
||||
|
||||
const renderGuard = (mobileSupported?: boolean) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/some/route/']}>
|
||||
<MobileRouteGuard mobileSupported={mobileSupported}>
|
||||
<div data-test="guarded-content">Content</div>
|
||||
</MobileRouteGuard>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
mockedUseIsMobile.mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('renders children on desktop regardless of mobileSupported', () => {
|
||||
renderGuard(undefined);
|
||||
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders children on mobile when the route is mobileSupported', () => {
|
||||
mockedUseIsMobile.mockReturnValue(true);
|
||||
renderGuard(true);
|
||||
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows the unsupported screen on mobile for unsupported routes', () => {
|
||||
mockedUseIsMobile.mockReturnValue(true);
|
||||
renderGuard(undefined);
|
||||
expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("This view isn't available on mobile"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders children on mobile when the bypass flag is set', () => {
|
||||
mockedUseIsMobile.mockReturnValue(true);
|
||||
sessionStorage.setItem(MOBILE_BYPASS_STORAGE_KEY, 'true');
|
||||
renderGuard(undefined);
|
||||
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
|
||||
});
|
||||
78
superset-frontend/src/components/MobileRouteGuard/index.tsx
Normal file
78
superset-frontend/src/components/MobileRouteGuard/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import MobileUnsupported, {
|
||||
MOBILE_BYPASS_STORAGE_KEY,
|
||||
} from 'src/pages/MobileUnsupported';
|
||||
|
||||
interface MobileRouteGuardProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Whether the wrapped route is part of the mobile consumption
|
||||
* experience. Set via the `mobileSupported` flag on the route
|
||||
* definition in `src/views/routes.tsx`.
|
||||
*/
|
||||
mobileSupported?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps route content and shows the MobileUnsupported page when a
|
||||
* non-mobile-friendly route is accessed on a small screen with
|
||||
* MOBILE_CONSUMPTION_MODE enabled.
|
||||
*
|
||||
* Users can bypass this by clicking "Continue anyway", which sets a
|
||||
* sessionStorage flag for the rest of the browser session.
|
||||
*/
|
||||
function MobileRouteGuard({
|
||||
children,
|
||||
mobileSupported,
|
||||
}: MobileRouteGuardProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const location = useLocation();
|
||||
const [bypassEnabled, setBypassEnabled] = useState(() => {
|
||||
try {
|
||||
return sessionStorage.getItem(MOBILE_BYPASS_STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Check for bypass flag when location changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
const bypass =
|
||||
sessionStorage.getItem(MOBILE_BYPASS_STORAGE_KEY) === 'true';
|
||||
setBypassEnabled(bypass);
|
||||
} catch {
|
||||
// Storage access denied, keep current state
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
if (!isMobile || bypassEnabled || mobileSupported) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileUnsupported originalPath={location.pathname + location.search} />
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileRouteGuard;
|
||||
@@ -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 { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import ResizableSidebar from 'src/components/ResizableSidebar';
|
||||
import {
|
||||
BUILDER_SIDEPANEL_WIDTH,
|
||||
@@ -142,9 +143,8 @@ const DashboardContentWrapper = styled.div`
|
||||
& .dashboard-component-tabs {
|
||||
box-shadow: 0 ${theme.sizeUnit}px ${theme.sizeUnit}px 0
|
||||
${addAlpha(theme.colorBorderSecondary, 0.1)};
|
||||
padding-left: ${
|
||||
theme.sizeUnit * 2
|
||||
}px; /* note this is added to tab-level padding, to match header */
|
||||
padding-left: ${theme.sizeUnit *
|
||||
2}px; /* note this is added to tab-level padding, to match header */
|
||||
}
|
||||
|
||||
.dropdown-toggle.btn.btn-primary .caret {
|
||||
@@ -297,14 +297,12 @@ const StyledDashboardContent = styled.div<{
|
||||
margin: ${theme.sizeUnit * 4}px;
|
||||
margin-left: ${marginLeft}px;
|
||||
|
||||
${
|
||||
editMode &&
|
||||
`
|
||||
${editMode &&
|
||||
`
|
||||
max-width: calc(100% - ${
|
||||
BUILDER_SIDEPANEL_WIDTH + theme.sizeUnit * 16
|
||||
}px);
|
||||
`
|
||||
}
|
||||
`}
|
||||
|
||||
/* this is the ParentSize wrapper */
|
||||
& > div:first-of-type {
|
||||
@@ -341,10 +339,8 @@ const StyledDashboardContent = styled.div<{
|
||||
}
|
||||
|
||||
&.fade-out {
|
||||
box-shadow: ${
|
||||
theme.dashboardTileBoxShadow ??
|
||||
`0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`
|
||||
};
|
||||
box-shadow: ${theme.dashboardTileBoxShadow ??
|
||||
`0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`};
|
||||
}
|
||||
|
||||
& .missing-chart-container {
|
||||
@@ -372,6 +368,8 @@ const DashboardBuilder = () => {
|
||||
const dispatch = useDispatch();
|
||||
const uiConfig = useUiConfig();
|
||||
const theme = useTheme();
|
||||
const isNotMobile = !useIsMobile();
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
|
||||
const dashboardId = useSelector<RootState, string>(
|
||||
({ dashboardInfo }) => `${dashboardInfo.id}`,
|
||||
@@ -462,13 +460,14 @@ const DashboardBuilder = () => {
|
||||
dashboardFiltersOpen,
|
||||
toggleDashboardFiltersOpen,
|
||||
nativeFiltersEnabled,
|
||||
hasFilters,
|
||||
} = useNativeFilters();
|
||||
|
||||
const [containerRef, isSticky] = useElementOnScreen<HTMLDivElement>(
|
||||
ELEMENT_ON_SCREEN_OPTIONS,
|
||||
);
|
||||
|
||||
const showFilterBar = !editMode && nativeFiltersEnabled;
|
||||
const showFilterBar = isNotMobile && !editMode && nativeFiltersEnabled;
|
||||
|
||||
const offset =
|
||||
FILTER_BAR_HEADER_HEIGHT +
|
||||
@@ -480,6 +479,7 @@ const DashboardBuilder = () => {
|
||||
const draggableStyle = useMemo(
|
||||
() => ({
|
||||
marginLeft:
|
||||
!isNotMobile ||
|
||||
dashboardFiltersOpen ||
|
||||
editMode ||
|
||||
!nativeFiltersEnabled ||
|
||||
@@ -488,6 +488,7 @@ const DashboardBuilder = () => {
|
||||
: -32,
|
||||
}),
|
||||
[
|
||||
isNotMobile,
|
||||
dashboardFiltersOpen,
|
||||
editMode,
|
||||
filterBarOrientation,
|
||||
@@ -519,7 +520,15 @@ const DashboardBuilder = () => {
|
||||
const headerContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{!hideDashboardHeader && <DashboardHeader />}
|
||||
{!hideDashboardHeader && (
|
||||
<DashboardHeader
|
||||
onOpenMobileFilters={
|
||||
!isNotMobile && nativeFiltersEnabled && hasFilters
|
||||
? () => setMobileFiltersOpen(true)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* Report mode is a one-shot screenshot render (reports, thumbnails),
|
||||
so it must never start a refresh timer that could re-fetch charts
|
||||
mid-capture. */}
|
||||
@@ -533,7 +542,15 @@ const DashboardBuilder = () => {
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[hideDashboardHeader, showFilterBar, filterBarOrientation, isReport],
|
||||
[
|
||||
hideDashboardHeader,
|
||||
isNotMobile,
|
||||
nativeFiltersEnabled,
|
||||
hasFilters,
|
||||
showFilterBar,
|
||||
filterBarOrientation,
|
||||
isReport,
|
||||
],
|
||||
);
|
||||
|
||||
const renderDraggableContent = useCallback(
|
||||
@@ -577,6 +594,8 @@ const DashboardBuilder = () => {
|
||||
topLevelTabs,
|
||||
uiConfig.hideTab,
|
||||
uiConfig.hideNav,
|
||||
isNotMobile,
|
||||
theme,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -739,6 +758,36 @@ const DashboardBuilder = () => {
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{/* Mobile filters drawer */}
|
||||
{!isNotMobile && nativeFiltersEnabled && (
|
||||
<Drawer
|
||||
title={t('Filters')}
|
||||
placement="left"
|
||||
onClose={() => setMobileFiltersOpen(false)}
|
||||
open={mobileFiltersOpen}
|
||||
width="85vw"
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<FilterBar
|
||||
orientation={FilterBarOrientation.Vertical}
|
||||
verticalConfig={{
|
||||
filtersOpen: true,
|
||||
toggleFiltersBar: () => {},
|
||||
width: 300,
|
||||
height: '100%',
|
||||
offset: 0,
|
||||
mobileMode: true,
|
||||
}}
|
||||
hidden={false}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
</DashboardWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useSelector } from 'react-redux';
|
||||
import { useDragDropManager } from 'react-dnd';
|
||||
import classNames from 'classnames';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
${({ theme }) => css`
|
||||
@@ -110,6 +111,20 @@ const StyledDiv = styled.div`
|
||||
i.warning {
|
||||
color: ${theme.colorWarning};
|
||||
}
|
||||
|
||||
/* Mobile consumption mode: show the full chart title without
|
||||
truncation (controls and links are render-gated in SliceHeader) */
|
||||
${isMobileConsumptionEnabled()
|
||||
? `@media (max-width: ${theme.screenSMMax}px) {
|
||||
[data-test='slice-header'] .header-title {
|
||||
-webkit-line-clamp: unset;
|
||||
display: block;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
}
|
||||
}`
|
||||
: ''}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -640,7 +652,7 @@ const Header = (): JSX.Element => {
|
||||
onTogglePause={handlePauseToggle}
|
||||
/>
|
||||
),
|
||||
!editMode && (
|
||||
!editMode && !isMobile && (
|
||||
<PublishedStatus
|
||||
key="published-status"
|
||||
dashboardId={dashboardInfo.id}
|
||||
@@ -650,12 +662,13 @@ const Header = (): JSX.Element => {
|
||||
userCanSave={userCanSaveAs}
|
||||
/>
|
||||
),
|
||||
!editMode && !isEmbedded && metadataBar,
|
||||
!editMode && !isEmbedded && !isMobile && metadataBar,
|
||||
],
|
||||
[
|
||||
boundActionCreators.savePublished,
|
||||
dashboardInfo.id,
|
||||
editMode,
|
||||
isMobile,
|
||||
metadataBar,
|
||||
isEmbedded,
|
||||
isPublished,
|
||||
@@ -750,7 +763,7 @@ const Header = (): JSX.Element => {
|
||||
) : (
|
||||
<div css={actionButtonsStyle}>
|
||||
{NavExtension && <NavExtension />}
|
||||
{userCanEdit && !isEmbedded && (
|
||||
{userCanEdit && !isEmbedded && !isMobile && (
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={handleEnterEditMode}
|
||||
@@ -778,6 +791,7 @@ const Header = (): JSX.Element => {
|
||||
handleEnterEditMode,
|
||||
hasUnsavedChanges,
|
||||
isEmbedded,
|
||||
isMobile,
|
||||
overwriteDashboard,
|
||||
redoLength,
|
||||
undoLength,
|
||||
@@ -814,6 +828,10 @@ const Header = (): JSX.Element => {
|
||||
userCanCurate,
|
||||
userCanExport,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isStarred,
|
||||
isPublished,
|
||||
saveFaveStar: boundActionCreators.saveFaveStar,
|
||||
showReportModal,
|
||||
showPropertiesModal,
|
||||
showRefreshModal,
|
||||
@@ -833,6 +851,21 @@ const Header = (): JSX.Element => {
|
||||
editableTitleProps={editableTitleProps}
|
||||
certificatiedBadgeProps={certifiedBadgeProps}
|
||||
faveStarProps={faveStarProps}
|
||||
leftPanelItems={
|
||||
onOpenMobileFilters && (
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
aria-label={t('Open filters')}
|
||||
onClick={onOpenMobileFilters}
|
||||
data-test="mobile-filters-trigger"
|
||||
>
|
||||
<Icons.FilterOutlined
|
||||
iconColor={theme.colorPrimary}
|
||||
iconSize="l"
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
titlePanelAdditionalItems={titlePanelAdditionalItems}
|
||||
rightPanelAdditionalItems={rightPanelAdditionalItems}
|
||||
menuDropdownProps={{
|
||||
@@ -840,7 +873,7 @@ const Header = (): JSX.Element => {
|
||||
onOpenChange: setIsDropdownVisible,
|
||||
}}
|
||||
additionalActionsMenu={menu}
|
||||
showFaveStar={Boolean(user?.userId && dashboardInfo?.id)}
|
||||
showFaveStar={!!(user?.userId && dashboardInfo?.id && !isMobile)}
|
||||
showTitlePanelItems
|
||||
/>
|
||||
{showingPropertiesModal && (
|
||||
|
||||
@@ -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 FiltersBadge from 'src/dashboard/components/FiltersBadge';
|
||||
import CustomizationsBadge from 'src/dashboard/components/CustomizationsBadge';
|
||||
@@ -229,7 +230,9 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
0,
|
||||
);
|
||||
|
||||
const canExplore = !editMode && supersetCanExplore;
|
||||
// Consumption-only mobile mode: no explore link, no chart controls
|
||||
const isMobile = useIsMobile();
|
||||
const canExplore = !editMode && supersetCanExplore && !isMobile;
|
||||
const showRowLimitWarning =
|
||||
shouldShowRowLimitWarning && sqlRowCount >= rowLimit && rowLimit > 0;
|
||||
|
||||
@@ -355,7 +358,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!uiConfig.hideChartControls && (
|
||||
{!uiConfig.hideChartControls && !isMobile && (
|
||||
<SliceHeaderControls
|
||||
slice={slice}
|
||||
isCached={isCached}
|
||||
|
||||
@@ -36,10 +36,12 @@ import { AntdThemeProvider } from '@superset-ui/core/components';
|
||||
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
|
||||
import {
|
||||
GRID_BASE_UNIT,
|
||||
GRID_COLUMN_COUNT,
|
||||
GRID_GUTTER_SIZE,
|
||||
GRID_MIN_COLUMN_COUNT,
|
||||
GRID_MIN_ROW_UNITS,
|
||||
} from 'src/dashboard/util/constants';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
|
||||
export const CHART_MARGIN = 32;
|
||||
|
||||
@@ -96,6 +98,7 @@ const ChartHolder = ({
|
||||
isInView,
|
||||
}: ChartHolderProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const fullSizeStyle = css`
|
||||
&& {
|
||||
position: fixed !important;
|
||||
@@ -167,6 +170,14 @@ const ChartHolder = ({
|
||||
}, [outlinedComponentId]);
|
||||
|
||||
const widthMultiple = useMemo(() => {
|
||||
// Mobile consumption mode stacks charts vertically at full width, so
|
||||
// report the full column count. This keeps the pixel width handed to the
|
||||
// chart plugin (and to ResizableContainer's inline size) in sync with the
|
||||
// stacked layout instead of the desktop grid fraction.
|
||||
if (isMobile && !editMode) {
|
||||
return GRID_COLUMN_COUNT;
|
||||
}
|
||||
|
||||
const columnParentWidth = getComponentById(
|
||||
parentComponent.parents?.find(parent => parent.startsWith(COLUMN_TYPE)),
|
||||
)?.meta?.width;
|
||||
@@ -182,6 +193,8 @@ const ChartHolder = ({
|
||||
}, [
|
||||
component,
|
||||
getComponentById,
|
||||
isMobile,
|
||||
editMode,
|
||||
parentComponent.meta.width,
|
||||
parentComponent.parents,
|
||||
parentComponent.type,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Droppable,
|
||||
} from 'src/dashboard/components/dnd/DragDroppable';
|
||||
import DragHandle from 'src/dashboard/components/dnd/DragHandle';
|
||||
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
|
||||
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
|
||||
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
|
||||
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
|
||||
@@ -120,6 +121,18 @@ const GridRow = styled.div<{ editMode: boolean }>`
|
||||
&.grid-row--empty {
|
||||
min-height: ${theme.sizeUnit * 25}px;
|
||||
}
|
||||
|
||||
${isMobileConsumptionEnabled() &&
|
||||
css`
|
||||
@media (max-width: ${theme.screenSMMax}px) {
|
||||
flex-direction: column;
|
||||
|
||||
& > :not(.hover-menu) {
|
||||
width: 100% !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
|
||||
import {
|
||||
LineEditableTabs,
|
||||
TabsProps as AntdTabsProps,
|
||||
@@ -81,6 +82,20 @@ const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
|
||||
display: none !important;
|
||||
}
|
||||
`}
|
||||
|
||||
/* Sticky tabs on mobile (consumption mode) */
|
||||
${({ theme }) =>
|
||||
isMobileConsumptionEnabled() &&
|
||||
css`
|
||||
@media (max-width: ${theme.screenSMMax}px) {
|
||||
.ant-tabs-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background-color: ${theme.colorBgContainer};
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export interface TabItem {
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -469,7 +469,8 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
);
|
||||
const pendingItems = (
|
||||
Object.values(pendingChartCustomizations).filter(Boolean) as (
|
||||
ChartCustomization | ChartCustomizationDivider
|
||||
| ChartCustomization
|
||||
| ChartCustomizationDivider
|
||||
)[]
|
||||
).filter(item => existingCustomizationIds.has(item.id));
|
||||
|
||||
@@ -680,6 +681,7 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
}
|
||||
toggleFiltersBar={verticalConfig.toggleFiltersBar}
|
||||
width={verticalConfig.width}
|
||||
mobileMode={verticalConfig.mobileMode}
|
||||
clearAllTriggers={clearAllTriggers}
|
||||
onClearAllComplete={handleClearAllComplete}
|
||||
/>
|
||||
|
||||
@@ -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 { Dashboard } from 'src/views/CRUD/types';
|
||||
import { assetUrl } from 'src/utils/assetUrl';
|
||||
import { SubjectPile } from 'src/features/subjects/SubjectPile';
|
||||
import { KebabMenuButton } from 'src/components';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
|
||||
interface DashboardCardProps {
|
||||
isChart?: boolean;
|
||||
@@ -63,6 +64,7 @@ function DashboardCard({
|
||||
handleBulkDashboardExport,
|
||||
onDelete,
|
||||
}: DashboardCardProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const history = useHistory();
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
@@ -174,10 +176,12 @@ function DashboardCard({
|
||||
isStarred={favoriteStatus}
|
||||
/>
|
||||
)}
|
||||
<KebabMenuButton
|
||||
menuItems={menuItems}
|
||||
dataTest="dashboard-card-menu"
|
||||
/>
|
||||
{!isMobile && (
|
||||
<KebabMenuButton
|
||||
menuItems={menuItems}
|
||||
dataTest="dashboard-card-menu"
|
||||
/>
|
||||
)}
|
||||
</ListViewCard.Actions>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -59,11 +59,15 @@ jest.mock('@apache-superset/core/theme', () => ({
|
||||
useTheme: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile menu rendering)
|
||||
// Note: We mock 'antd' directly rather than '@superset-ui/core/components' because
|
||||
// mocking the latter causes circular dependency issues with ActionButton during
|
||||
// jest.requireActual evaluation. Since Grid is re-exported from antd, this works.
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({ md: true }),
|
||||
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import {
|
||||
MenuObjectChildProps,
|
||||
@@ -199,6 +200,7 @@ export function Menu({
|
||||
isFrontendRoute = () => false,
|
||||
}: MenuProps) {
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = useIsMobile();
|
||||
const uiConfig = useUiConfig();
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -400,7 +402,18 @@ export function Menu({
|
||||
aria-label={t('Main navigation')}
|
||||
>
|
||||
<StyledRow>
|
||||
<StyledCol md={16} xs={24}>
|
||||
{/* Mobile: left placeholder for future icon */}
|
||||
{isMobile && <Col xs={4} />}
|
||||
<StyledCol
|
||||
md={16}
|
||||
xs={isMobile ? 16 : 24}
|
||||
css={
|
||||
isMobile &&
|
||||
css`
|
||||
justify-content: center;
|
||||
`
|
||||
}
|
||||
>
|
||||
{!brand.hide_logo && (
|
||||
<Tooltip
|
||||
id="brand-tooltip"
|
||||
@@ -416,40 +429,44 @@ export function Menu({
|
||||
<span>{brand.text}</span>
|
||||
</StyledBrandText>
|
||||
)}
|
||||
<StyledMainNav
|
||||
mode={screens.md ? 'horizontal' : 'inline'}
|
||||
data-test="navbar-top"
|
||||
className="main-nav"
|
||||
selectedKeys={activeTabs}
|
||||
disabledOverflow
|
||||
items={menu.map(item => {
|
||||
const props = {
|
||||
...item,
|
||||
label: t(item.label),
|
||||
isFrontendRoute: isFrontendRoute(item.url),
|
||||
childs: item.childs?.map(c => {
|
||||
if (typeof c === 'string') {
|
||||
return c;
|
||||
}
|
||||
{/* Consumption mode: hide nav items on mobile (drawer holds them) */}
|
||||
{!isMobile && (
|
||||
<StyledMainNav
|
||||
mode={screens.md ? 'horizontal' : 'inline'}
|
||||
data-test="navbar-top"
|
||||
className="main-nav"
|
||||
selectedKeys={activeTabs}
|
||||
disabledOverflow
|
||||
items={menu.map(item => {
|
||||
const props = {
|
||||
...item,
|
||||
label: t(item.label),
|
||||
isFrontendRoute: isFrontendRoute(item.url),
|
||||
childs: item.childs?.map(c => {
|
||||
if (typeof c === 'string') {
|
||||
return c;
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
isFrontendRoute: isFrontendRoute(c.url),
|
||||
};
|
||||
}),
|
||||
};
|
||||
return {
|
||||
...c,
|
||||
isFrontendRoute: isFrontendRoute(c.url),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return buildMenuItem(props);
|
||||
})}
|
||||
/>
|
||||
return buildMenuItem(props);
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</StyledCol>
|
||||
<Col md={8} xs={24}>
|
||||
<Col md={8} xs={isMobile ? 4 : 24}>
|
||||
<RightMenu
|
||||
align={screens.md ? 'flex-end' : 'flex-start'}
|
||||
align={screens.md || isMobile ? 'flex-end' : 'flex-start'}
|
||||
settings={settings}
|
||||
navbarRight={navbarRight}
|
||||
isFrontendRoute={isFrontendRoute}
|
||||
environmentTag={environmentTag}
|
||||
menu={menu}
|
||||
/>
|
||||
</Col>
|
||||
</StyledRow>
|
||||
|
||||
@@ -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,12 @@ import {
|
||||
Icons,
|
||||
Typography,
|
||||
TelemetryPixel,
|
||||
Drawer,
|
||||
Button,
|
||||
} from '@superset-ui/core/components';
|
||||
import type { ItemType, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
@@ -104,12 +107,10 @@ const StyledMenuItem = styled.div<{ disabled?: boolean }>`
|
||||
color: ${!disabled && theme.colorPrimary};
|
||||
cursor: ${!disabled ? 'pointer' : 'not-allowed'};
|
||||
}
|
||||
${
|
||||
disabled &&
|
||||
css`
|
||||
color: ${theme.colorTextDisabled};
|
||||
`
|
||||
}
|
||||
${disabled &&
|
||||
css`
|
||||
color: ${theme.colorTextDisabled};
|
||||
`}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -119,6 +120,7 @@ const RightMenu = ({
|
||||
navbarRight,
|
||||
isFrontendRoute,
|
||||
environmentTag,
|
||||
menu,
|
||||
setQuery,
|
||||
}: RightMenuProps & {
|
||||
setQuery: ({
|
||||
@@ -130,6 +132,8 @@ const RightMenu = ({
|
||||
}) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const user = useSelector<any, UserWithPermissionsAndRoles>(
|
||||
state => state.user,
|
||||
);
|
||||
@@ -646,6 +650,64 @@ const RightMenu = ({
|
||||
handleLogout,
|
||||
]);
|
||||
|
||||
// Build mobile menu items - consumption only (no create/admin actions)
|
||||
const mobileMenuItems = useMemo(() => {
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
// Add Dashboards link at top (from main menu)
|
||||
// Match on the FAB-internal `name`, which is stable across locales
|
||||
// (`label` is translated and would break in non-English deployments)
|
||||
const dashboardsMenu = menu?.find(item => item.name === 'Dashboards');
|
||||
if (dashboardsMenu) {
|
||||
const dashboardUrl = dashboardsMenu.url || '/dashboard/list/';
|
||||
items.push({
|
||||
key: 'dashboards',
|
||||
label: isFrontendRoute(dashboardUrl) ? (
|
||||
<Link to={dashboardUrl}>{t('Dashboards')}</Link>
|
||||
) : (
|
||||
<Typography.Link href={dashboardUrl}>
|
||||
{t('Dashboards')}
|
||||
</Typography.Link>
|
||||
),
|
||||
icon: <Icons.DashboardOutlined />,
|
||||
});
|
||||
}
|
||||
|
||||
// Add theme menu (flatten children directly)
|
||||
menuItems.forEach(item => {
|
||||
if (!item || !('key' in item)) return;
|
||||
|
||||
// Only include theme-sub-menu and language picker
|
||||
if (item.key === 'theme-sub-menu' || item.key === 'language-picker') {
|
||||
items.push({ type: 'divider', key: `divider-before-${item.key}` });
|
||||
|
||||
if ('children' in item && item.children) {
|
||||
// Theme menu already has a nested group, so just add its children directly
|
||||
item.children.forEach(child => {
|
||||
items.push(child);
|
||||
});
|
||||
} else {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract user-related items from settings
|
||||
if (item.key === 'settings' && 'children' in item && item.children) {
|
||||
item.children.forEach(child => {
|
||||
if (!child || !('key' in child)) return;
|
||||
|
||||
// Only include user-section and about-section
|
||||
if (child.key === 'user-section' || child.key === 'about-section') {
|
||||
items.push({ type: 'divider', key: `divider-before-${child.key}` });
|
||||
items.push(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [menu, menuItems, isFrontendRoute]);
|
||||
|
||||
return (
|
||||
<StyledDiv align={align}>
|
||||
{canDatabase && (
|
||||
@@ -706,48 +768,87 @@ const RightMenu = ({
|
||||
</Tag>
|
||||
);
|
||||
})()}
|
||||
<Menu
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
border-bottom: none !important;
|
||||
|
||||
/* Remove the underline from menu items */
|
||||
.ant-menu-item:after,
|
||||
.ant-menu-submenu:after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
.submenu-with-caret {
|
||||
{/* Mobile: hamburger menu with drawer */}
|
||||
{isMobile && (
|
||||
<>
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
aria-label={t('Menu')}
|
||||
>
|
||||
<Icons.MenuOutlined iconSize="l" />
|
||||
</Button>
|
||||
<Drawer
|
||||
title={null}
|
||||
placement="right"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
width={280}
|
||||
styles={{
|
||||
header: { display: 'none' },
|
||||
body: { padding: 0 },
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectable={false}
|
||||
onClick={info => {
|
||||
handleMenuSelection(info);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
items={mobileMenuItems}
|
||||
css={css`
|
||||
border-inline-end: none !important;
|
||||
`}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
)}
|
||||
{/* Desktop: horizontal menu */}
|
||||
{!isMobile && (
|
||||
<Menu
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
.ant-menu-submenu-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
flex-direction: row-reverse;
|
||||
border-bottom: none !important;
|
||||
|
||||
/* Remove the underline from menu items */
|
||||
.ant-menu-item:after,
|
||||
.ant-menu-submenu:after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
.submenu-with-caret {
|
||||
height: 100%;
|
||||
}
|
||||
&.ant-menu-submenu::after {
|
||||
inset-inline: ${theme.sizeUnit}px;
|
||||
}
|
||||
&.ant-menu-submenu:hover,
|
||||
&.ant-menu-submenu-active {
|
||||
.ant-menu-title-content {
|
||||
color: ${theme.colorPrimary};
|
||||
padding: 0;
|
||||
.ant-menu-submenu-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
flex-direction: row-reverse;
|
||||
height: 100%;
|
||||
}
|
||||
&.ant-menu-submenu::after {
|
||||
inset-inline: ${theme.sizeUnit}px;
|
||||
}
|
||||
&.ant-menu-submenu:hover,
|
||||
&.ant-menu-submenu-active {
|
||||
.ant-menu-title-content {
|
||||
color: ${theme.colorPrimary};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
selectable={false}
|
||||
mode="horizontal"
|
||||
onClick={handleMenuSelection}
|
||||
onOpenChange={onMenuOpen}
|
||||
disabledOverflow
|
||||
items={menuItems}
|
||||
/>
|
||||
`}
|
||||
selectable={false}
|
||||
mode="horizontal"
|
||||
onClick={handleMenuSelection}
|
||||
onOpenChange={onMenuOpen}
|
||||
disabledOverflow
|
||||
items={menuItems}
|
||||
/>
|
||||
)}
|
||||
{navbarRight.documentation_url && (
|
||||
<>
|
||||
<StyledAnchor
|
||||
|
||||
@@ -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 {
|
||||
|
||||
44
superset-frontend/src/hooks/useIsMobile.ts
Normal file
44
superset-frontend/src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { Grid } from '@superset-ui/core/components';
|
||||
|
||||
/**
|
||||
* Whether the mobile consumption-only experience is enabled for this
|
||||
* deployment. Non-hook variant for use inside styled-component
|
||||
* interpolations; prefer `useIsMobile` in components.
|
||||
*/
|
||||
export function isMobileConsumptionEnabled(): boolean {
|
||||
return isFeatureEnabled(FeatureFlag.MobileConsumptionMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the viewport is below antd's `md` breakpoint AND the
|
||||
* MOBILE_CONSUMPTION_MODE feature flag is enabled. All mobile-specific
|
||||
* behavior (route guarding, consumption-only chrome, drawer navigation)
|
||||
* should key off this hook so the flag remains a single kill switch.
|
||||
*
|
||||
* Defaults to desktop on the first render, before antd's
|
||||
* ResponsiveObserver has fired, so the initial paint never takes the
|
||||
* mobile branch by accident.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
const { md = true } = Grid.useBreakpoint();
|
||||
return !md && isMobileConsumptionEnabled();
|
||||
}
|
||||
@@ -45,6 +45,15 @@ jest.mock('src/utils/export', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
|
||||
typeof isFeatureEnabled
|
||||
>;
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
SupersetClient,
|
||||
handleKeyboardActivation,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -39,7 +39,9 @@ import Subject from 'src/types/Subject';
|
||||
import { SUBJECT_OPTION_FILTER_PROPS } from 'src/features/subjects/SubjectSelectLabel';
|
||||
import { SubjectPile } from 'src/features/subjects/SubjectPile';
|
||||
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import {
|
||||
Button,
|
||||
CertifiedBadge,
|
||||
ConfirmStatusChange,
|
||||
DeleteModal,
|
||||
@@ -175,6 +177,9 @@ const DASHBOARD_COLUMNS_TO_FETCH = [
|
||||
|
||||
function DashboardList(props: DashboardListProps) {
|
||||
const { addDangerToast, addSuccessToast, user } = props;
|
||||
const isNotMobile = !useIsMobile();
|
||||
const theme = useTheme();
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
|
||||
state => state.user,
|
||||
);
|
||||
@@ -824,7 +829,25 @@ function DashboardList(props: DashboardListProps) {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SubMenu name={t('Dashboards')} buttons={subMenuButtons} />
|
||||
<SubMenu
|
||||
name={t('Dashboards')}
|
||||
buttons={subMenuButtons}
|
||||
leftIcon={
|
||||
!isNotMobile ? (
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
onClick={() => setMobileFiltersOpen(true)}
|
||||
aria-label={t('Search')}
|
||||
css={css`
|
||||
padding: 0;
|
||||
margin-right: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
>
|
||||
<Icons.SearchOutlined iconSize="l" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={t(
|
||||
@@ -913,8 +936,14 @@ function DashboardList(props: DashboardListProps) {
|
||||
? 'card'
|
||||
: 'table'
|
||||
}
|
||||
forceViewMode={!isNotMobile ? 'card' : undefined}
|
||||
enableBulkTag={enableBulkTag}
|
||||
bulkTagResourceName="dashboard"
|
||||
mobileFiltersOpen={mobileFiltersOpen}
|
||||
setMobileFiltersOpen={
|
||||
!isNotMobile ? setMobileFiltersOpen : undefined
|
||||
}
|
||||
mobileFiltersDrawerTitle={t('Search Dashboards')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
189
superset-frontend/src/pages/Home/Home.mobile.test.tsx
Normal file
189
superset-frontend/src/pages/Home/Home.mobile.test.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mobile-specific tests for the Home/Welcome page.
|
||||
*
|
||||
* These tests verify that certain desktop-only sections are hidden
|
||||
* on mobile viewports.
|
||||
*/
|
||||
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import Welcome from 'src/pages/Home';
|
||||
|
||||
// Mock useBreakpoint to return MOBILE breakpoints
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({
|
||||
xs: true,
|
||||
sm: true,
|
||||
md: false, // Mobile: md is false
|
||||
lg: false,
|
||||
xl: false,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Enable only the mobile consumption mode flag so the mobile branch renders
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(
|
||||
(flag: string) => flag === 'MOBILE_CONSUMPTION_MODE',
|
||||
),
|
||||
}));
|
||||
|
||||
// API mocks
|
||||
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
|
||||
const chartInfoEndpoint = 'glob:*/api/v1/chart/_info?*';
|
||||
const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status?*';
|
||||
const dashboardsEndpoint = 'glob:*/api/v1/dashboard/?*';
|
||||
const dashboardInfoEndpoint = 'glob:*/api/v1/dashboard/_info?*';
|
||||
const dashboardFavoriteStatusEndpoint =
|
||||
'glob:*/api/v1/dashboard/favorite_status/?*';
|
||||
const savedQueryEndpoint = 'glob:*/api/v1/saved_query/?*';
|
||||
const savedQueryInfoEndpoint = 'glob:*/api/v1/saved_query/_info?*';
|
||||
const recentActivityEndpoint = 'glob:*/api/v1/log/recent_activity/*';
|
||||
|
||||
fetchMock.get(chartsEndpoint, {
|
||||
result: [
|
||||
{
|
||||
slice_name: 'ChartyChart',
|
||||
changed_on_utc: '24 Feb 2014 10:13:14',
|
||||
url: '/fakeUrl/explore',
|
||||
id: '4',
|
||||
table: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fetchMock.get(dashboardsEndpoint, {
|
||||
result: [
|
||||
{
|
||||
dashboard_title: 'Dashboard_Test',
|
||||
changed_on_utc: '24 Feb 2014 10:13:14',
|
||||
url: '/fakeUrl/dashboard',
|
||||
id: '3',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fetchMock.get(savedQueryEndpoint, {
|
||||
result: [],
|
||||
});
|
||||
|
||||
fetchMock.get(recentActivityEndpoint, {
|
||||
result: [
|
||||
{
|
||||
action: 'dashboard',
|
||||
item_title: "World Bank's Data",
|
||||
item_type: 'dashboard',
|
||||
item_url: '/superset/dashboard/world_health/',
|
||||
time: 1741644942130.566,
|
||||
time_delta_humanized: 'a day ago',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fetchMock.get(chartInfoEndpoint, { permissions: [] });
|
||||
fetchMock.get(chartFavoriteStatusEndpoint, { result: [] });
|
||||
fetchMock.get(dashboardInfoEndpoint, { permissions: [] });
|
||||
fetchMock.get(dashboardFavoriteStatusEndpoint, { result: [] });
|
||||
fetchMock.get(savedQueryInfoEndpoint, { permissions: [] });
|
||||
|
||||
const mockedProps = {
|
||||
user: {
|
||||
username: 'alpha',
|
||||
firstName: 'alpha',
|
||||
lastName: 'alpha',
|
||||
createdOn: '2016-11-11T12:34:17',
|
||||
userId: 5,
|
||||
email: 'alpha@alpha.com',
|
||||
isActive: true,
|
||||
isAnonymous: false,
|
||||
permissions: {},
|
||||
roles: {
|
||||
sql_lab: [['can_read', 'SavedQuery']],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderWelcome = (props = mockedProps) =>
|
||||
waitFor(() => {
|
||||
render(<Welcome {...props} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.clearHistory();
|
||||
});
|
||||
|
||||
test('Mobile view - renders Dashboards panel', async () => {
|
||||
await renderWelcome();
|
||||
expect(await screen.findByText('Dashboards')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - renders Recents panel', async () => {
|
||||
await renderWelcome();
|
||||
expect(await screen.findByText('Recents')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - does NOT render Charts panel', async () => {
|
||||
await renderWelcome();
|
||||
|
||||
// Wait for Dashboards to ensure the component has rendered
|
||||
await screen.findByText('Dashboards');
|
||||
|
||||
// Charts panel should NOT be present on mobile
|
||||
// Look specifically for the Charts collapse panel header
|
||||
const chartsPanel = screen.queryByRole('button', { name: /Charts/i });
|
||||
expect(chartsPanel).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - does NOT render Saved queries panel', async () => {
|
||||
await renderWelcome();
|
||||
|
||||
// Wait for Dashboards to ensure the component has rendered
|
||||
await screen.findByText('Dashboards');
|
||||
|
||||
// Saved queries panel should NOT be present on mobile
|
||||
const savedQueriesPanel = screen.queryByRole('button', {
|
||||
name: /Saved queries/i,
|
||||
});
|
||||
expect(savedQueriesPanel).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Mobile view - only shows 2 panels (Recents and Dashboards)', async () => {
|
||||
await renderWelcome();
|
||||
|
||||
// Wait for content to load
|
||||
await screen.findByText('Dashboards');
|
||||
|
||||
// Should only have Recents and Dashboards panels visible
|
||||
// Charts and Saved queries are hidden on mobile
|
||||
const recentsPanel = screen.queryByText('Recents');
|
||||
const dashboardsPanel = screen.queryByText('Dashboards');
|
||||
|
||||
expect(recentsPanel).toBeInTheDocument();
|
||||
expect(dashboardsPanel).toBeInTheDocument();
|
||||
});
|
||||
@@ -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 { User } from 'src/types/bootstrapTypes';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import { reject } from 'lodash-es';
|
||||
import {
|
||||
dangerouslyGetItemDoNotUse,
|
||||
@@ -147,6 +148,7 @@ export const LoadingCards = ({ cover }: LoadingProps) => (
|
||||
);
|
||||
|
||||
function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
const isNotMobile = !useIsMobile();
|
||||
const canReadSavedQueries = userHasPermission(user, 'SavedQuery', 'can_read');
|
||||
const userid = user.userId;
|
||||
const id = userid!.toString(); // confident that user is not a guest user
|
||||
@@ -397,24 +399,29 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'charts',
|
||||
label: t('Charts'),
|
||||
children:
|
||||
!chartData || isRecentActivityLoading ? (
|
||||
<LoadingCards cover={checked} />
|
||||
) : (
|
||||
<ChartTable
|
||||
showThumbnails={checked}
|
||||
user={user}
|
||||
mine={chartData}
|
||||
otherTabData={activityData?.[TableTab.Other]}
|
||||
otherTabFilters={otherTabFilters}
|
||||
otherTabTitle={otherTabTitle}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(canReadSavedQueries
|
||||
// Hide Charts and Saved queries on mobile - consumption-only mode
|
||||
...(isNotMobile
|
||||
? [
|
||||
{
|
||||
key: 'charts',
|
||||
label: t('Charts'),
|
||||
children:
|
||||
!chartData || isRecentActivityLoading ? (
|
||||
<LoadingCards cover={checked} />
|
||||
) : (
|
||||
<ChartTable
|
||||
showThumbnails={checked}
|
||||
user={user}
|
||||
mine={chartData}
|
||||
otherTabData={activityData?.[TableTab.Other]}
|
||||
otherTabFilters={otherTabFilters}
|
||||
otherTabTitle={otherTabTitle}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isNotMobile && canReadSavedQueries
|
||||
? [
|
||||
{
|
||||
key: 'saved-queries',
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import MobileUnsupported from './index';
|
||||
|
||||
// Mock useBreakpoint to return mobile by default
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => ({
|
||||
xs: true,
|
||||
sm: true,
|
||||
md: false,
|
||||
lg: false,
|
||||
xl: false,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useHistory
|
||||
const mockPush = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Store original sessionStorage
|
||||
const originalSessionStorage = window.sessionStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore sessionStorage
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: originalSessionStorage,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
const renderComponent = (props = {}) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/chart/list/']}>
|
||||
<MobileUnsupported {...props} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
test('renders the page title', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByText("This view isn't available on mobile"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the description text', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Some features require a larger screen. Try viewing dashboards for the best mobile experience.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the View Dashboards button', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'View Dashboards' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the Go to Welcome Page button', () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Go to Welcome Page' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the Continue anyway link', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText(/Continue anyway/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('View Dashboards button navigates to dashboard list', async () => {
|
||||
renderComponent();
|
||||
|
||||
const button = screen.getByRole('button', { name: 'View Dashboards' });
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/dashboard/list/');
|
||||
});
|
||||
|
||||
test('Go to Welcome Page button navigates to welcome page', async () => {
|
||||
renderComponent();
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Go to Welcome Page' });
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/welcome/');
|
||||
});
|
||||
|
||||
test('Continue anyway sets sessionStorage and navigates to original path', async () => {
|
||||
renderComponent({ originalPath: '/chart/list/' });
|
||||
|
||||
const link = screen.getByText(/Continue anyway/);
|
||||
await userEvent.click(link);
|
||||
|
||||
expect(sessionStorage.getItem('mobile-bypass')).toBe('true');
|
||||
expect(mockPush).toHaveBeenCalledWith('/chart/list/');
|
||||
});
|
||||
|
||||
test('uses originalPath prop when provided', async () => {
|
||||
renderComponent({ originalPath: '/explore/?form_data=123' });
|
||||
|
||||
const link = screen.getByText(/Continue anyway/);
|
||||
await userEvent.click(link);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/explore/?form_data=123');
|
||||
});
|
||||
|
||||
test('handles sessionStorage errors gracefully', async () => {
|
||||
// Mock sessionStorage to throw
|
||||
const mockStorage = {
|
||||
getItem: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
setItem: jest.fn(() => {
|
||||
throw new Error('Storage access denied');
|
||||
}),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
length: 0,
|
||||
key: jest.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: mockStorage,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
renderComponent({ originalPath: '/chart/list/' });
|
||||
|
||||
const link = screen.getByText(/Continue anyway/);
|
||||
// Should not throw even though sessionStorage fails
|
||||
await userEvent.click(link);
|
||||
|
||||
// Should still navigate even if storage failed
|
||||
expect(mockPush).toHaveBeenCalledWith('/chart/list/');
|
||||
});
|
||||
|
||||
test('renders desktop icon', () => {
|
||||
renderComponent();
|
||||
// The icon should be present (DesktopOutlined)
|
||||
// Icon might not have aria-label, so we verify the page renders with the title
|
||||
expect(
|
||||
screen.getByText("This view isn't available on mobile"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
196
superset-frontend/src/pages/MobileUnsupported/index.tsx
Normal file
196
superset-frontend/src/pages/MobileUnsupported/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Button, Grid } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
/** sessionStorage key for the "Continue anyway" mobile bypass */
|
||||
export const MOBILE_BYPASS_STORAGE_KEY = 'mobile-bypass';
|
||||
|
||||
interface MobileUnsupportedProps {
|
||||
/** The original path the user was trying to access */
|
||||
originalPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A mobile-friendly page shown when users try to access
|
||||
* features that aren't supported on mobile devices.
|
||||
*/
|
||||
function MobileUnsupported({ originalPath }: MobileUnsupportedProps) {
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const screens = useBreakpoint();
|
||||
|
||||
const fromPath = originalPath || location.pathname;
|
||||
|
||||
const handleViewDashboards = useCallback(() => {
|
||||
history.push('/dashboard/list/');
|
||||
}, [history]);
|
||||
|
||||
const handleGoHome = useCallback(() => {
|
||||
history.push('/welcome/');
|
||||
}, [history]);
|
||||
|
||||
const handleContinueAnyway = useCallback(() => {
|
||||
// Store preference in sessionStorage so we don't keep redirecting
|
||||
try {
|
||||
sessionStorage.setItem(MOBILE_BYPASS_STORAGE_KEY, 'true');
|
||||
} catch {
|
||||
// Storage access denied, continue anyway without persisting
|
||||
}
|
||||
history.push(fromPath);
|
||||
}, [history, fromPath]);
|
||||
|
||||
// Determine if we're at or above the 'md' breakpoint (i.e. not on mobile)
|
||||
const isNotMobile = screens.md;
|
||||
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 60px);
|
||||
padding: ${theme.sizeUnit * 6}px;
|
||||
text-align: center;
|
||||
background: ${theme.colorBgContainer};
|
||||
`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
css={css`
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: ${theme.colorBgLayout};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: ${theme.sizeUnit * 6}px;
|
||||
`}
|
||||
>
|
||||
<Icons.DesktopOutlined
|
||||
iconSize="xxl"
|
||||
iconColor={theme.colorTextSecondary}
|
||||
css={css`
|
||||
font-size: 48px;
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1
|
||||
css={css`
|
||||
font-size: ${theme.fontSizeXL}px;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${theme.colorText};
|
||||
margin: 0 0 ${theme.sizeUnit * 2}px 0;
|
||||
`}
|
||||
>
|
||||
{t("This view isn't available on mobile")}
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
css={css`
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
color: ${theme.colorTextSecondary};
|
||||
margin: 0 0 ${theme.sizeUnit * 8}px 0;
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
`}
|
||||
>
|
||||
{t(
|
||||
'Some features require a larger screen. Try viewing dashboards for the best mobile experience.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Primary action */}
|
||||
<div>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
onClick={handleViewDashboards}
|
||||
css={css`
|
||||
width: 280px;
|
||||
height: 48px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{t('View Dashboards')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Secondary action */}
|
||||
<div
|
||||
css={css`
|
||||
margin-top: ${theme.sizeUnit * 3}px;
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={handleGoHome}
|
||||
css={css`
|
||||
width: 280px;
|
||||
height: 48px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{t('Go to Welcome Page')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Continue anyway link */}
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
onClick={handleContinueAnyway}
|
||||
css={css`
|
||||
margin-top: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
{t('Continue anyway')}
|
||||
<Icons.ArrowRightOutlined iconSize="s" />
|
||||
</Button>
|
||||
|
||||
{/* Show hint if screen is now larger */}
|
||||
{isNotMobile && (
|
||||
<p
|
||||
css={css`
|
||||
margin-top: ${theme.sizeUnit * 6}px;
|
||||
font-size: ${theme.fontSizeXS}px;
|
||||
color: ${theme.colorTextDescription};
|
||||
`}
|
||||
>
|
||||
{t('Your screen is now large enough.')}
|
||||
<Button buttonStyle="link" onClick={handleContinueAnyway}>
|
||||
{t('Continue to page')}
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileUnsupported;
|
||||
@@ -29,6 +29,7 @@ import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Flex, Layout, Loading } from '@superset-ui/core/components';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { ErrorBoundary } from 'src/components';
|
||||
import MobileRouteGuard from 'src/components/MobileRouteGuard';
|
||||
import Menu from 'src/features/home/Menu';
|
||||
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
|
||||
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
|
||||
@@ -91,19 +92,29 @@ const RouteSwitch = () => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Switch>
|
||||
{routes.map(({ path, Component, props = {}, Fallback = Loading }) => (
|
||||
<Route path={path} key={path}>
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<ErrorBoundary
|
||||
css={css`
|
||||
margin: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
<Component user={bootstrapData.user} {...props} />
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</Route>
|
||||
))}
|
||||
{routes.map(
|
||||
({
|
||||
path,
|
||||
Component,
|
||||
props = {},
|
||||
Fallback = Loading,
|
||||
mobileSupported,
|
||||
}) => (
|
||||
<Route path={path} key={path}>
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<MobileRouteGuard mobileSupported={mobileSupported}>
|
||||
<ErrorBoundary
|
||||
css={css`
|
||||
margin: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
<Component user={bootstrapData.user} {...props} />
|
||||
</ErrorBoundary>
|
||||
</MobileRouteGuard>
|
||||
</Suspense>
|
||||
</Route>
|
||||
),
|
||||
)}
|
||||
<Redirect from="/" to="/welcome/" exact />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
lruCache,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
|
||||
import Chart from 'src/types/Chart';
|
||||
import { intersection } from 'lodash-es';
|
||||
import rison from 'rison';
|
||||
@@ -293,7 +294,8 @@ const createFetchSubjectRelation =
|
||||
...result,
|
||||
data: result.data.flatMap(item => {
|
||||
const secondaryLabel = item.extra?.secondary_label as
|
||||
string | undefined;
|
||||
| string
|
||||
| undefined;
|
||||
const type = item.extra?.type as number | undefined;
|
||||
const value = normalizeSubjectToPickerValue({
|
||||
value: item.value,
|
||||
@@ -456,6 +458,17 @@ export const CardContainer = styled.div<{
|
||||
? `${theme.sizeUnit * 8 + 3}px ${theme.sizeUnit * 20}px`
|
||||
: `${theme.sizeUnit * 8 + 1}px ${theme.sizeUnit * 20}px`
|
||||
};
|
||||
|
||||
/* Full-width cards on mobile (consumption mode) */
|
||||
${
|
||||
isMobileConsumptionEnabled()
|
||||
? `@media (max-width: ${theme.screenSMMax}px) {
|
||||
grid-template-columns: 1fr;
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
padding-right: ${theme.sizeUnit * 4}px;
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -707,6 +707,11 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
|
||||
# Enable Matrixify feature for matrix-style chart layouts
|
||||
# @lifecycle: development
|
||||
"MATRIXIFY": False,
|
||||
# Serve a consumption-only mobile experience (dashboards, dashboard list,
|
||||
# and home page) on small screens; other views show a "not supported on
|
||||
# mobile" screen. Authoring features are hidden on mobile when enabled.
|
||||
# @lifecycle: development
|
||||
"MOBILE_CONSUMPTION_MODE": False,
|
||||
# Try to optimize SQL queries — for now only predicate pushdown is supported
|
||||
# @lifecycle: development
|
||||
"OPTIMIZE_SQL": False,
|
||||
|
||||
Reference in New Issue
Block a user