mirror of
https://github.com/apache/superset.git
synced 2026-08-03 20:42:30 +00:00
Adds an opt-in, consumption-only mobile experience (feature flag MOBILE_CONSUMPTION_MODE, default off, @lifecycle: development): - Dashboards: charts stacked full-width with real plugin dimensions (ChartHolder reports full column count on mobile; heights capped to the viewport minus chrome), sticky swipeable tab bars with gradient overflow affordances, filter bar in a drawer (FilterBar mobileMode), compact header (title scrolls away; edit/publish/fave/refresh controls hidden; dashboard info moved into the kebab menu) - Dashboard list: forced card view, full-width cards, search/filters and sort in a drawer (single FilterControls instance) - Home: dashboards-only Recents, compact empty states, desktop-only sections hidden - Navigation: hamburger drawer (dashboards, theme/language, user info/logout with row-tap navigation) - Route guarding: routes declare mobileSupported in routes.tsx; everything else renders a MobileUnsupported screen; viewport growth unblocks automatically (useIsMobile subscribes to matchMedia only when the flag is on, so flag-off deployments have zero render delta) - Serves a viewport meta tag (flag-gated) so mobile browsers lay out at device width instead of the ~980px legacy viewport; exposes is_feature_enabled to Jinja via the common context processor - User docs (using-superset/mobile-experience.mdx) with a Playwright screenshot generator following the docs:screenshots pattern - Docker dev config enables the flag; jest + Playwright coverage throughout Squashed from the iterative mobile-dashboard-support history (preserved at backup/mobile-pre-rebase-2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
336 lines
9.7 KiB
TypeScript
336 lines
9.7 KiB
TypeScript
/**
|
|
* 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 {
|
|
cloneElement,
|
|
memo,
|
|
ReactElement,
|
|
RefObject,
|
|
useCallback,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { css, styled } from '@apache-superset/core/theme';
|
|
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
|
|
import {
|
|
LineEditableTabs,
|
|
TabsProps as AntdTabsProps,
|
|
} from '@superset-ui/core/components/Tabs';
|
|
import type { DragEndEvent } from '@dnd-kit/core';
|
|
import {
|
|
DndContext,
|
|
PointerSensor,
|
|
useSensor,
|
|
closestCenter,
|
|
} from '@dnd-kit/core';
|
|
import {
|
|
horizontalListSortingStrategy,
|
|
SortableContext,
|
|
useSortable,
|
|
} from '@dnd-kit/sortable';
|
|
import HoverMenu from '../../menu/HoverMenu';
|
|
import DragHandle from '../../dnd/DragHandle';
|
|
import DeleteComponentButton from '../../DeleteComponentButton';
|
|
|
|
const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
|
|
width: 100%;
|
|
background-color: ${({ theme }) => theme.colorBgContainer};
|
|
|
|
& .dashboard-component-tabs-content {
|
|
height: 100%;
|
|
}
|
|
|
|
& > .hover-menu:hover {
|
|
opacity: 1;
|
|
}
|
|
|
|
&.dragdroppable-row .dashboard-component-tabs-content {
|
|
height: calc(100% - 47px);
|
|
}
|
|
|
|
/* Ensure tab labels maintain full opacity during drag */
|
|
.ant-tabs-tab {
|
|
.dragdroppable-tab,
|
|
.editable-title,
|
|
textarea {
|
|
opacity: 1;
|
|
color: inherit;
|
|
}
|
|
}
|
|
|
|
${({ isDragging }) =>
|
|
isDragging &&
|
|
`
|
|
/* Show the drag indicator during drag, over the tab title textarea too.
|
|
The doubled parent outranks the title's own cursor; a single & loses. */
|
|
&& .dragdroppable-tab * {
|
|
cursor: move;
|
|
}
|
|
|
|
/* Hide ink-bar during drag */
|
|
.ant-tabs-card > .ant-tabs-nav .ant-tabs-ink-bar,
|
|
.ant-tabs > .ant-tabs-nav .ant-tabs-ink-bar {
|
|
display: none !important;
|
|
}
|
|
`}
|
|
|
|
/* Sticky tabs on mobile (consumption mode) */
|
|
${({ theme }) =>
|
|
isMobileConsumptionEnabled() &&
|
|
css`
|
|
@media (max-width: ${theme.screenSMMax}px) {
|
|
.ant-tabs-nav {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
background-color: ${theme.colorBgContainer};
|
|
/* breathing room between the tab bar and the first card; padding
|
|
(not margin) so the gap is part of the opaque sticky bar */
|
|
padding-bottom: ${theme.sizeUnit * 2}px;
|
|
}
|
|
|
|
/* Scrollability affordance: fade the clipped edge with a
|
|
theme-colored gradient. antd toggles the ping classes when tabs
|
|
overflow on that side; restyle its shadow elements as gradients,
|
|
which read much better than the default shadows on dark themes. */
|
|
.ant-tabs-nav-wrap:before,
|
|
.ant-tabs-nav-wrap:after {
|
|
width: ${theme.sizeUnit * 10}px;
|
|
box-shadow: none !important;
|
|
pointer-events: none;
|
|
}
|
|
.ant-tabs-nav-wrap-ping-right:after {
|
|
background: linear-gradient(
|
|
to right,
|
|
transparent,
|
|
${theme.colorBgContainer}
|
|
);
|
|
opacity: 1;
|
|
}
|
|
.ant-tabs-nav-wrap-ping-left:before {
|
|
background: linear-gradient(
|
|
to left,
|
|
transparent,
|
|
${theme.colorBgContainer}
|
|
);
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Swipeable tab bar instead of the overflow dropdown: the "more"
|
|
menu is a poor touch target and duplicates half-clipped tabs.
|
|
antd's tab nav supports touch-drag scrolling natively and shows
|
|
edge shadows (ping classes) when tabs overflow. */
|
|
.ant-tabs-nav-operations {
|
|
display: none !important;
|
|
}
|
|
}
|
|
`}
|
|
`;
|
|
|
|
export interface TabItem {
|
|
key: string;
|
|
label: ReactElement;
|
|
closeIcon: ReactElement;
|
|
children?: ReactElement;
|
|
}
|
|
|
|
export interface TabsComponent {
|
|
id: string;
|
|
}
|
|
|
|
export interface TabsRendererProps {
|
|
tabItems: TabItem[];
|
|
editMode: boolean;
|
|
renderHoverMenu?: boolean;
|
|
tabsDragSourceRef?: RefObject<HTMLDivElement>;
|
|
handleDeleteComponent: () => void;
|
|
tabsComponent: TabsComponent;
|
|
activeKey: string;
|
|
tabIds: string[];
|
|
handleClickTab: (index: number) => void;
|
|
handleEdit: AntdTabsProps['onEdit'];
|
|
tabBarPaddingLeft?: number;
|
|
onTabsReorder?: (oldIndex: number, newIndex: number) => void;
|
|
isEditingTabTitle?: boolean;
|
|
onTabTitleEditingChange?: (isEditing: boolean) => void;
|
|
}
|
|
|
|
interface DraggableTabNodeProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
'data-node-key': string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({
|
|
disabled = false,
|
|
...props
|
|
}) => {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({
|
|
id: props['data-node-key'],
|
|
disabled,
|
|
});
|
|
|
|
const style: React.CSSProperties = {
|
|
...props.style,
|
|
position: 'relative',
|
|
transform: transform ? `translate3d(${transform.x}px, 0, 0)` : undefined,
|
|
transition: isDragging ? 'none' : transition,
|
|
cursor: disabled ? 'default' : 'move',
|
|
zIndex: isDragging ? 1000 : 'auto',
|
|
opacity: 1,
|
|
};
|
|
|
|
return cloneElement(props.children as React.ReactElement, {
|
|
ref: setNodeRef,
|
|
style,
|
|
...attributes,
|
|
...(disabled ? {} : listeners),
|
|
});
|
|
};
|
|
|
|
/**
|
|
* TabsRenderer component handles the rendering of dashboard tabs
|
|
* Extracted from the main Tabs component for better separation of concerns
|
|
*/
|
|
const TabsRenderer = memo<TabsRendererProps>(
|
|
({
|
|
tabItems,
|
|
editMode,
|
|
renderHoverMenu = true,
|
|
tabsDragSourceRef,
|
|
handleDeleteComponent,
|
|
tabsComponent,
|
|
activeKey,
|
|
tabIds,
|
|
handleClickTab,
|
|
handleEdit,
|
|
tabBarPaddingLeft = 0,
|
|
onTabsReorder,
|
|
isEditingTabTitle = false,
|
|
}) => {
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
|
|
// Use ref to always have access to the current tabIds in callbacks
|
|
const tabIdsRef = useRef(tabIds);
|
|
tabIdsRef.current = tabIds;
|
|
|
|
const sensor = useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 10 },
|
|
});
|
|
|
|
const onDragStart = useCallback((event: any) => {
|
|
setActiveId(event.active.id);
|
|
}, []);
|
|
|
|
const onDragEnd = useCallback(
|
|
({ active, over }: DragEndEvent) => {
|
|
const currentTabIds = tabIdsRef.current;
|
|
// Only reorder when we have a valid drop target and both IDs are found
|
|
if (active.id !== over?.id && onTabsReorder) {
|
|
const activeIndex = currentTabIds.findIndex(id => id === active.id);
|
|
const overIndex = currentTabIds.findIndex(id => id === over?.id);
|
|
if (activeIndex !== -1 && overIndex !== -1) {
|
|
onTabsReorder(activeIndex, overIndex);
|
|
}
|
|
}
|
|
setActiveId(null);
|
|
},
|
|
[onTabsReorder],
|
|
);
|
|
|
|
const onDragCancel = useCallback(() => {
|
|
setActiveId(null);
|
|
}, []);
|
|
|
|
const isDragging = activeId !== null;
|
|
|
|
return (
|
|
<StyledTabsContainer
|
|
className="dashboard-component dashboard-component-tabs"
|
|
data-test="dashboard-component-tabs"
|
|
isDragging={isDragging}
|
|
>
|
|
{editMode && renderHoverMenu && tabsDragSourceRef && (
|
|
<HoverMenu innerRef={tabsDragSourceRef} position="left">
|
|
<DragHandle position="left" />
|
|
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
|
</HoverMenu>
|
|
)}
|
|
|
|
<LineEditableTabs
|
|
id={tabsComponent.id}
|
|
activeKey={activeKey}
|
|
onChange={key => {
|
|
if (typeof key === 'string') {
|
|
const tabIndex = tabIds.indexOf(key);
|
|
if (tabIndex !== -1) handleClickTab(tabIndex);
|
|
}
|
|
}}
|
|
onEdit={handleEdit}
|
|
data-test="nav-list"
|
|
type={editMode ? 'editable-card' : 'card'}
|
|
items={tabItems}
|
|
tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
|
|
fullHeight
|
|
{...(editMode && {
|
|
renderTabBar: (tabBarProps, DefaultTabBar) => (
|
|
<DndContext
|
|
key={tabIds.join('-')}
|
|
sensors={[sensor]}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
onDragCancel={onDragCancel}
|
|
collisionDetection={closestCenter}
|
|
>
|
|
<SortableContext
|
|
items={tabIds}
|
|
strategy={horizontalListSortingStrategy}
|
|
>
|
|
<DefaultTabBar {...tabBarProps}>
|
|
{(node: React.ReactElement) => (
|
|
<DraggableTabNode
|
|
{...(node as React.ReactElement<DraggableTabNodeProps>)
|
|
.props}
|
|
key={node.key}
|
|
data-node-key={node.key as string}
|
|
disabled={isEditingTabTitle}
|
|
>
|
|
{node}
|
|
</DraggableTabNode>
|
|
)}
|
|
</DefaultTabBar>
|
|
</SortableContext>
|
|
</DndContext>
|
|
),
|
|
})}
|
|
/>
|
|
</StyledTabsContainer>
|
|
);
|
|
},
|
|
);
|
|
|
|
TabsRenderer.displayName = 'TabsRenderer';
|
|
|
|
export default TabsRenderer;
|