mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
442 lines
13 KiB
TypeScript
442 lines
13 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 {
|
|
Fragment,
|
|
useState,
|
|
useCallback,
|
|
useRef,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
memo,
|
|
RefObject,
|
|
} from 'react';
|
|
import cx from 'classnames';
|
|
import { t } from '@apache-superset/core/translation';
|
|
import { FeatureFlag, isFeatureEnabled, JsonObject } from '@superset-ui/core';
|
|
import { css, styled, SupersetTheme } from '@apache-superset/core/theme';
|
|
import { Icons } from '@superset-ui/core/components';
|
|
import {
|
|
Draggable,
|
|
Droppable,
|
|
} from 'src/dashboard/components/dnd/DragDroppable';
|
|
import DragHandle from 'src/dashboard/components/dnd/DragHandle';
|
|
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
|
|
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
|
|
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
|
|
import IconButton from 'src/dashboard/components/IconButton';
|
|
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
|
|
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
|
|
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
|
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
|
|
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
|
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
|
|
import { isCurrentUserBot } from 'src/utils/isBot';
|
|
|
|
export type RowProps = {
|
|
id: string;
|
|
parentId: string;
|
|
component: JsonObject;
|
|
parentComponent: JsonObject;
|
|
index: number;
|
|
depth: number;
|
|
editMode: boolean;
|
|
|
|
// grid related
|
|
availableColumnCount: number;
|
|
columnWidth: number;
|
|
occupiedColumnCount: number;
|
|
maxChildrenHeight: number;
|
|
|
|
onResizeStart: (e: unknown, direction: unknown) => void;
|
|
onResize: (e: unknown, direction: unknown, ref: HTMLElement) => void;
|
|
onResizeStop: (e: unknown, direction: unknown, ref: HTMLElement) => void;
|
|
|
|
// dnd
|
|
handleComponentDrop: (dropResult: unknown) => void;
|
|
deleteComponent: (id: string, parentId: string) => void;
|
|
updateComponents: (updates: Record<string, JsonObject>) => void;
|
|
|
|
// visibility
|
|
isComponentVisible: boolean;
|
|
onChangeTab: (args: { pathToTabIndex: string[] }) => void;
|
|
};
|
|
|
|
const GridRow = styled.div<{ editMode: boolean }>`
|
|
${({ theme, editMode }) => css`
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex-wrap: nowrap;
|
|
align-items: flex-start;
|
|
width: 100%;
|
|
height: fit-content;
|
|
|
|
& > :not(:last-child):not(.hover-menu) {
|
|
${!editMode && `margin-right: ${theme.sizeUnit * 4}px;`}
|
|
}
|
|
|
|
& .empty-droptarget {
|
|
position: relative;
|
|
align-self: center;
|
|
&.empty-droptarget--vertical {
|
|
min-width: ${theme.sizeUnit * 4}px;
|
|
&:not(:last-child) {
|
|
width: ${theme.sizeUnit * 4}px;
|
|
}
|
|
&:first-of-type:not(.droptarget-side) {
|
|
z-index: ${EMPTY_CONTAINER_Z_INDEX};
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
}
|
|
&.droptarget-side {
|
|
z-index: ${EMPTY_CONTAINER_Z_INDEX};
|
|
position: absolute;
|
|
width: ${theme.sizeUnit * 4}px;
|
|
&:first-of-type {
|
|
inset-inline-start: 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
&.grid-row--empty {
|
|
min-height: ${theme.sizeUnit * 25}px;
|
|
}
|
|
`}
|
|
`;
|
|
|
|
const emptyRowContentStyles = (theme: SupersetTheme) => css`
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: ${theme.colorTextLabel};
|
|
`;
|
|
|
|
const Row = memo((props: RowProps) => {
|
|
const {
|
|
component: rowComponent,
|
|
parentComponent,
|
|
index,
|
|
availableColumnCount,
|
|
columnWidth,
|
|
occupiedColumnCount,
|
|
depth,
|
|
onResizeStart,
|
|
onResize,
|
|
onResizeStop,
|
|
handleComponentDrop,
|
|
editMode,
|
|
onChangeTab,
|
|
isComponentVisible,
|
|
updateComponents,
|
|
deleteComponent,
|
|
parentId,
|
|
} = props;
|
|
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const [isInView, setIsInView] = useState(false);
|
|
const [hoverMenuHovered, setHoverMenuHovered] = useState(false);
|
|
const [containerHeight, setContainerHeight] = useState<number | null>(null);
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const isComponentVisibleRef = useRef(isComponentVisible);
|
|
|
|
useEffect(() => {
|
|
isComponentVisibleRef.current = isComponentVisible;
|
|
}, [isComponentVisible]);
|
|
|
|
// if chart not rendered - render it if it's less than 1 view height away from current viewport
|
|
// if chart rendered - remove it if it's more than 4 view heights away from current viewport
|
|
useEffect(() => {
|
|
let observerEnabler: IntersectionObserver | undefined;
|
|
let observerDisabler: IntersectionObserver | undefined;
|
|
|
|
if (
|
|
isFeatureEnabled(FeatureFlag.DashboardVirtualization) &&
|
|
!isCurrentUserBot()
|
|
) {
|
|
observerEnabler = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting && isComponentVisibleRef.current) {
|
|
setIsInView(true);
|
|
} else if (!isComponentVisibleRef.current) {
|
|
setIsInView(false);
|
|
}
|
|
},
|
|
{
|
|
rootMargin: '100% 0px',
|
|
},
|
|
);
|
|
|
|
observerDisabler = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (!entry.isIntersecting && isComponentVisibleRef.current) {
|
|
// Reference: https://www.w3.org/TR/intersection-observer/#dom-intersectionobserver-rootmargin
|
|
if (!isEmbedded()) {
|
|
setIsInView(false);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
rootMargin: '400% 0px',
|
|
},
|
|
);
|
|
|
|
const element = containerRef.current;
|
|
if (element) {
|
|
observerEnabler.observe(element);
|
|
observerDisabler.observe(element);
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
observerEnabler?.disconnect();
|
|
observerDisabler?.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!editMode) return;
|
|
const updatedHeight = containerRef.current?.clientHeight;
|
|
if (updatedHeight !== undefined && updatedHeight !== containerHeight) {
|
|
setContainerHeight(updatedHeight);
|
|
}
|
|
});
|
|
|
|
const handleChangeFocus = useCallback((nextFocus: boolean) => {
|
|
setIsFocused(Boolean(nextFocus));
|
|
}, []);
|
|
|
|
const handleChangeBackground = useCallback(
|
|
(nextValue: string) => {
|
|
const metaKey = 'background';
|
|
if (nextValue && rowComponent.meta?.[metaKey] !== nextValue) {
|
|
updateComponents({
|
|
[rowComponent.id as string]: {
|
|
...rowComponent,
|
|
meta: {
|
|
...rowComponent.meta,
|
|
[metaKey]: nextValue,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
},
|
|
[updateComponents, rowComponent],
|
|
);
|
|
|
|
const handleDeleteComponent = useCallback(() => {
|
|
deleteComponent(rowComponent.id as string, parentId);
|
|
}, [deleteComponent, rowComponent, parentId]);
|
|
|
|
const handleMenuHover = useCallback((hover: { isHovered: boolean }) => {
|
|
setHoverMenuHovered(hover.isHovered);
|
|
}, []);
|
|
|
|
const rowItems: string[] = useMemo(
|
|
() =>
|
|
Array.isArray(rowComponent.children)
|
|
? (rowComponent.children as string[])
|
|
: [],
|
|
[rowComponent.children],
|
|
);
|
|
|
|
const backgroundStyle =
|
|
backgroundStyleOptions.find(
|
|
opt =>
|
|
opt.value === (rowComponent.meta?.background ?? BACKGROUND_TRANSPARENT),
|
|
) ?? backgroundStyleOptions[0];
|
|
|
|
const remainColumnCount = availableColumnCount - occupiedColumnCount;
|
|
const renderChild = useCallback(
|
|
({ dragSourceRef }: { dragSourceRef: RefObject<HTMLDivElement> }) => (
|
|
<WithPopoverMenu
|
|
isFocused={isFocused}
|
|
onChangeFocus={handleChangeFocus}
|
|
disableClick
|
|
menuItems={[
|
|
<BackgroundStyleDropdown
|
|
id={`${rowComponent.id}-background`}
|
|
value={backgroundStyle.value}
|
|
onChange={handleChangeBackground}
|
|
/>,
|
|
]}
|
|
editMode={editMode}
|
|
>
|
|
{editMode && (
|
|
<HoverMenu
|
|
onHover={handleMenuHover}
|
|
innerRef={dragSourceRef}
|
|
position="left"
|
|
>
|
|
<DragHandle position="left" />
|
|
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
|
<IconButton
|
|
onClick={() => handleChangeFocus(true)}
|
|
label={t('Row settings')}
|
|
hideVisibleLabel
|
|
icon={<Icons.SettingOutlined iconSize="l" />}
|
|
/>
|
|
</HoverMenu>
|
|
)}
|
|
<GridRow
|
|
className={cx(
|
|
'grid-row',
|
|
rowItems.length === 0 && 'grid-row--empty',
|
|
hoverMenuHovered && 'grid-row--hovered',
|
|
backgroundStyle.className,
|
|
)}
|
|
data-test={`grid-row-${backgroundStyle.className}`}
|
|
ref={containerRef}
|
|
editMode={editMode}
|
|
>
|
|
{editMode && (
|
|
<Droppable
|
|
{...(rowItems.length === 0
|
|
? {
|
|
component: rowComponent,
|
|
parentComponent: rowComponent,
|
|
dropToChild: true,
|
|
}
|
|
: {
|
|
component: rowItems[0],
|
|
parentComponent: rowComponent,
|
|
})}
|
|
depth={depth}
|
|
index={0}
|
|
orientation="row"
|
|
onDrop={handleComponentDrop}
|
|
className={cx(
|
|
'empty-droptarget',
|
|
'empty-droptarget--vertical',
|
|
rowItems.length > 0 && 'droptarget-side',
|
|
)}
|
|
editMode
|
|
style={{
|
|
height: rowItems.length > 0 ? containerHeight : '100%',
|
|
...(rowItems.length > 0 && { width: 16 }),
|
|
}}
|
|
>
|
|
{({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) =>
|
|
dropIndicatorProps && <div {...dropIndicatorProps} />
|
|
}
|
|
</Droppable>
|
|
)}
|
|
{rowItems.length === 0 && (
|
|
<div css={emptyRowContentStyles as any}>{t('Empty row')}</div>
|
|
)}
|
|
{rowItems.length > 0 &&
|
|
rowItems.map((componentId, itemIndex) => (
|
|
<Fragment key={componentId}>
|
|
<DashboardComponent
|
|
key={componentId}
|
|
id={componentId}
|
|
parentId={rowComponent.id as string}
|
|
depth={depth + 1}
|
|
index={itemIndex}
|
|
availableColumnCount={remainColumnCount}
|
|
columnWidth={columnWidth}
|
|
onResizeStart={onResizeStart}
|
|
onResize={onResize}
|
|
onResizeStop={onResizeStop}
|
|
isComponentVisible={isComponentVisible}
|
|
onChangeTab={onChangeTab}
|
|
isInView={isInView}
|
|
/>
|
|
{editMode && (
|
|
<Droppable
|
|
component={rowItems}
|
|
parentComponent={rowComponent}
|
|
depth={depth}
|
|
index={itemIndex + 1}
|
|
orientation="row"
|
|
onDrop={handleComponentDrop}
|
|
className={cx(
|
|
'empty-droptarget',
|
|
'empty-droptarget--vertical',
|
|
remainColumnCount === 0 &&
|
|
itemIndex === rowItems.length - 1 &&
|
|
'droptarget-side',
|
|
)}
|
|
editMode
|
|
style={{
|
|
height: containerHeight,
|
|
...(remainColumnCount === 0 &&
|
|
itemIndex === rowItems.length - 1 && { width: 16 }),
|
|
}}
|
|
>
|
|
{({
|
|
dropIndicatorProps,
|
|
}: {
|
|
dropIndicatorProps: JsonObject;
|
|
}) => dropIndicatorProps && <div {...dropIndicatorProps} />}
|
|
</Droppable>
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
</GridRow>
|
|
</WithPopoverMenu>
|
|
),
|
|
[
|
|
backgroundStyle.className,
|
|
backgroundStyle.value,
|
|
columnWidth,
|
|
containerHeight,
|
|
depth,
|
|
editMode,
|
|
handleChangeBackground,
|
|
handleChangeFocus,
|
|
handleComponentDrop,
|
|
handleDeleteComponent,
|
|
handleMenuHover,
|
|
hoverMenuHovered,
|
|
isComponentVisible,
|
|
isFocused,
|
|
isInView,
|
|
onChangeTab,
|
|
onResize,
|
|
onResizeStart,
|
|
onResizeStop,
|
|
remainColumnCount,
|
|
rowComponent,
|
|
rowItems,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<Draggable
|
|
component={rowComponent}
|
|
parentComponent={parentComponent}
|
|
orientation="row"
|
|
index={index}
|
|
depth={depth}
|
|
onDrop={handleComponentDrop}
|
|
editMode={editMode}
|
|
>
|
|
{renderChild}
|
|
</Draggable>
|
|
);
|
|
});
|
|
|
|
export default Row;
|