Compare commits

...

5 Commits

Author SHA1 Message Date
Elizabeth Thompson
59f36997cf fix(reports): pre-commit tab permalinks before state machine transaction
When a dashboard report targets specific tabs, BaseReportState calls
CreateDashboardPermalinkCommand inside the outer @transaction() on
AsyncExecuteReportScheduleCommand.run(). The @transaction() nesting guard
(g.in_transaction) causes the command to only flush the permalink INSERT,
leaving the row invisible to Playwright's separate DB connection. Flask
returns a 404 from dashboard_permalink, <body class="standalone"> never
appears, and the screenshot times out after 600 s.

Fix: remove @transaction() from AsyncExecuteReportScheduleCommand.run()
and add a pre-warm call to get_dashboard_urls() before the state machine
starts. Executed outside any transaction, the call lets
CreateDashboardPermalinkCommand commit normally via its own @transaction().
The state machine's subsequent call to get_dashboard_urls() finds the
already-committed row through get_entry() (same deterministic UUID) and
returns it without a second INSERT. ReportScheduleStateMachine.run()
retains its own @transaction() and remains the outermost transaction owner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 00:15:23 +00:00
Evan Rusackas
6e2db42d98 chore(lint): convert dashboard components to function components (#39460)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-06-15 16:39:12 -07:00
yousoph
28aedc82c3 fix(upload): database field shows validation warning after selecting a database (#41078) 2026-06-15 16:38:24 -07:00
Evan Rusackas
f56524bb71 chore(frontend): remove unused modules flagged by knip (#41072)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:38:00 -07:00
Evan Rusackas
4ae9980e4c chore(ci): remove unused Claude PR Assistant workflow (#41081)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:37:39 -07:00
37 changed files with 2446 additions and 2680 deletions

View File

@@ -3,10 +3,6 @@ enable-beta-ecosystems: true
updates: updates:
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
ignore:
# Ignore temporarily as release schedule is too mentally taxing for dep-handling maintainers
# Additionally, very few PRs are reviewed by this action.
- dependency-name: anthropics/claude-code-action
schedule: schedule:
interval: "daily" interval: "daily"
cooldown: cooldown:

View File

@@ -1,88 +0,0 @@
name: Claude PR Assistant
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
permissions:
contents: read
jobs:
check-permissions:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude'))
runs-on: ubuntu-latest
outputs:
allowed: ${{ steps.check.outputs.allowed }}
steps:
- name: Check if user is allowed
id: check
env:
COMMENTER: ${{ github.event.comment.user.login }}
run: |
# List of allowed users
ALLOWED_USERS="mistercrunch,rusackas"
echo "Checking permissions for user: $COMMENTER"
# Check if user is in allowed list
if [[ ",$ALLOWED_USERS," == *",$COMMENTER,"* ]]; then
echo "allowed=true" >> $GITHUB_OUTPUT
echo "✅ User $COMMENTER is allowed to use Claude"
else
echo "allowed=false" >> $GITHUB_OUTPUT
echo "❌ User $COMMENTER is not allowed to use Claude"
fi
deny-access:
needs: check-permissions
if: needs.check-permissions.outputs.allowed == 'false'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Comment access denied
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COMMENTER_LOGIN: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}
with:
script: |
const commenter = process.env.COMMENTER_LOGIN;
const message = `👋 Hi @${commenter}!
Thanks for trying to use Claude Code, but currently only certain team members have access to this feature.
If you believe you should have access, please contact a project maintainer.`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
claude-code-action:
needs: check-permissions
if: needs.check-permissions.outputs.allowed == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@5fb899572b81d2bb648d4d187173a2f423a9677c # beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { PureComponent, ReactNode } from 'react'; import { ReactNode, useCallback, useContext, useEffect, useRef } from 'react';
import { t } from '@apache-superset/core/translation'; import { t } from '@apache-superset/core/translation';
import { JsonObject } from '@superset-ui/core'; import { JsonObject } from '@superset-ui/core';
@@ -90,165 +90,61 @@ interface VisibilityEventData {
ts: number; ts: number;
} }
class Dashboard extends PureComponent<DashboardProps> { function unload(event: BeforeUnloadEvent): string {
static contextType = PluginContext; const message = t('You have unsaved changes.');
// Set returnValue on the actual event object to trigger the browser prompt
event.returnValue = message;
return message; // Gecko + Webkit, Safari, Chrome etc.
}
// Use type assertion when accessing context instead of declare field function onBeforeUnload(hasChanged: boolean): void {
// to avoid babel transformation issues in Jest if (hasChanged) {
window.addEventListener('beforeunload', unload);
static defaultProps = { } else {
timeout: 60, window.removeEventListener('beforeunload', unload);
userId: '',
};
appliedFilters: ActiveFilters;
appliedOwnDataCharts: JsonObject;
visibilityEventData: VisibilityEventData;
static onBeforeUnload(hasChanged: boolean): void {
if (hasChanged) {
window.addEventListener('beforeunload', Dashboard.unload);
} else {
window.removeEventListener('beforeunload', Dashboard.unload);
}
} }
}
static unload(): string { function Dashboard({
const message = t('You have unsaved changes.'); actions,
// Gecko + IE: returnValue is typed as boolean but historically accepts string dashboardId,
(window.event as BeforeUnloadEvent).returnValue = message; editMode,
return message; // Gecko + Webkit, Safari, Chrome etc. isPublished,
} hasUnsavedChanges,
slices,
activeFilters,
chartConfiguration,
datasources,
ownDataCharts,
layout,
impressionId,
timeout = 60,
userId = '',
children,
}: DashboardProps): JSX.Element {
const context = useContext(PluginContext) as PluginContextType;
constructor(props: DashboardProps) { // Use refs to track mutable values that persist across renders
super(props); const appliedFiltersRef = useRef<ActiveFilters>(activeFilters ?? {});
this.appliedFilters = props.activeFilters ?? {}; const appliedOwnDataChartsRef = useRef<JsonObject>(ownDataCharts ?? {});
this.appliedOwnDataCharts = props.ownDataCharts ?? {}; const visibilityEventDataRef = useRef<VisibilityEventData>({
this.visibilityEventData = { start_offset: 0, ts: 0 }; start_offset: 0,
this.onVisibilityChange = this.onVisibilityChange.bind(this); ts: 0,
} });
const prevLayoutRef = useRef<DashboardLayout>(layout);
const prevDashboardIdRef = useRef<number>(dashboardId);
componentDidMount(): void { const refreshCharts = useCallback(
const bootstrapData = getBootstrapData(); (ids: (string | number)[]): void => {
const { editMode, isPublished, layout } = this.props; ids.forEach(id => {
const eventData: Record<string, unknown> = { actions.triggerQuery(true, id);
is_soft_navigation: Logger.timeOriginOffset > 0,
is_edit_mode: editMode,
mount_duration: Logger.getTimestamp(),
is_empty: isDashboardEmpty(layout),
is_published: isPublished,
bootstrap_data_length: JSON.stringify(bootstrapData).length,
};
const directLinkComponentId = getLocationHash();
if (directLinkComponentId) {
eventData.target_id = directLinkComponentId;
}
this.props.actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
// Handle browser tab visibility change
if (document.visibilityState === 'hidden') {
this.visibilityEventData = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
}
window.addEventListener('visibilitychange', this.onVisibilityChange);
this.applyCharts();
}
componentDidUpdate(prevProps: DashboardProps): void {
this.applyCharts();
const currentChartIds = getChartIdsFromLayout(prevProps.layout);
const nextChartIds = getChartIdsFromLayout(this.props.layout);
if (prevProps.dashboardId !== this.props.dashboardId) {
// single-page-app navigation check
return;
}
if (currentChartIds.length < nextChartIds.length) {
const newChartIds = nextChartIds.filter(
key => currentChartIds.indexOf(key) === -1,
);
newChartIds.forEach(newChartId =>
this.props.actions.addSliceToDashboard(
newChartId,
getLayoutComponentFromChartId(this.props.layout, newChartId),
),
);
} else if (currentChartIds.length > nextChartIds.length) {
// remove chart
const removedChartIds = currentChartIds.filter(
key => nextChartIds.indexOf(key) === -1,
);
removedChartIds.forEach(removedChartId =>
this.props.actions.removeSliceFromDashboard(removedChartId),
);
}
}
applyCharts(): void {
const {
activeFilters,
ownDataCharts,
chartConfiguration,
hasUnsavedChanges,
editMode,
} = this.props;
const { appliedFilters, appliedOwnDataCharts } = this;
if (!chartConfiguration) {
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
// for correct comparing of filters to avoid unnecessary requests
return;
}
if (
!editMode &&
(!areObjectsEqual(appliedOwnDataCharts, ownDataCharts, {
ignoreUndefined: true,
}) ||
!areObjectsEqual(appliedFilters, activeFilters, {
ignoreUndefined: true,
}))
) {
this.applyFilters();
}
if (hasUnsavedChanges) {
Dashboard.onBeforeUnload(true);
} else {
Dashboard.onBeforeUnload(false);
}
}
componentWillUnmount(): void {
window.removeEventListener('visibilitychange', this.onVisibilityChange);
this.props.actions.clearDataMaskState();
this.props.actions.clearAllChartStates();
}
onVisibilityChange(): void {
if (document.visibilityState === 'hidden') {
// from visible to hidden
this.visibilityEventData = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
} else if (document.visibilityState === 'visible') {
// from hidden to visible
const logStart = this.visibilityEventData.start_offset;
this.props.actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
...this.visibilityEventData,
duration: Logger.getTimestamp() - logStart,
}); });
} },
} [actions],
);
applyFilters(): void { const applyFilters = useCallback((): void => {
const { appliedFilters } = this; const appliedFilters = appliedFiltersRef.current;
const { activeFilters, ownDataCharts, slices } = this.props;
// refresh charts if a filter was removed, added, or changed // refresh charts if a filter was removed, added, or changed
@@ -258,7 +154,7 @@ class Dashboard extends PureComponent<DashboardProps> {
const allKeys = new Set(currFilterKeys.concat(appliedFilterKeys)); const allKeys = new Set(currFilterKeys.concat(appliedFilterKeys));
const affectedChartIds: (string | number)[] = getAffectedOwnDataCharts( const affectedChartIds: (string | number)[] = getAffectedOwnDataCharts(
ownDataCharts, ownDataCharts,
this.appliedOwnDataCharts, appliedOwnDataChartsRef.current,
); );
[...allKeys].forEach(filterKey => { [...allKeys].forEach(filterKey => {
@@ -321,24 +217,157 @@ class Dashboard extends PureComponent<DashboardProps> {
}); });
// remove dup in affectedChartIds // remove dup in affectedChartIds
this.refreshCharts([...new Set(affectedChartIds)]); refreshCharts([...new Set(affectedChartIds)]);
this.appliedFilters = activeFilters; appliedFiltersRef.current = activeFilters;
this.appliedOwnDataCharts = ownDataCharts; appliedOwnDataChartsRef.current = ownDataCharts;
} }, [activeFilters, ownDataCharts, slices, refreshCharts]);
refreshCharts(ids: (string | number)[]): void { const applyCharts = useCallback((): void => {
ids.forEach(id => { if (!chartConfiguration) {
this.props.actions.triggerQuery(true, id); // For a first loading we need to wait for cross filters charts data loaded to get all active filters
}); // for correct comparing of filters to avoid unnecessary requests
} return;
render(): ReactNode {
const context = this.context as PluginContextType;
if (context.loading) {
return <Loading />;
} }
return this.props.children;
if (
!editMode &&
(!areObjectsEqual(appliedOwnDataChartsRef.current, ownDataCharts, {
ignoreUndefined: true,
}) ||
!areObjectsEqual(appliedFiltersRef.current, activeFilters, {
ignoreUndefined: true,
}))
) {
applyFilters();
}
if (hasUnsavedChanges) {
onBeforeUnload(true);
} else {
onBeforeUnload(false);
}
}, [
chartConfiguration,
editMode,
ownDataCharts,
activeFilters,
hasUnsavedChanges,
applyFilters,
]);
const onVisibilityChange = useCallback((): void => {
if (document.visibilityState === 'hidden') {
// from visible to hidden
visibilityEventDataRef.current = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
} else if (document.visibilityState === 'visible') {
// from hidden to visible
const logStart = visibilityEventDataRef.current.start_offset;
actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
...visibilityEventDataRef.current,
duration: Logger.getTimestamp() - logStart,
});
}
}, [actions]);
// Refs that always point at the latest closures so the mount-only effect's
// listeners/cleanup never invoke a stale `actions` closure when `actions`
// identity changes.
const onVisibilityChangeRef = useRef(onVisibilityChange);
const actionsRef = useRef(actions);
useEffect(() => {
onVisibilityChangeRef.current = onVisibilityChange;
actionsRef.current = actions;
});
// componentDidMount equivalent
useEffect(() => {
const bootstrapData = getBootstrapData();
const eventData: Record<string, unknown> = {
is_soft_navigation: Logger.timeOriginOffset > 0,
is_edit_mode: editMode,
mount_duration: Logger.getTimestamp(),
is_empty: isDashboardEmpty(layout),
is_published: isPublished,
bootstrap_data_length: JSON.stringify(bootstrapData).length,
};
const directLinkComponentId = getLocationHash();
if (directLinkComponentId) {
eventData.target_id = directLinkComponentId;
}
actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
// Handle browser tab visibility change
if (document.visibilityState === 'hidden') {
visibilityEventDataRef.current = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
}
const handleVisibilityChange = () => onVisibilityChangeRef.current();
document.addEventListener('visibilitychange', handleVisibilityChange);
// componentWillUnmount equivalent
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
onBeforeUnload(false); // Remove beforeunload listener on unmount
actionsRef.current.clearDataMaskState();
actionsRef.current.clearAllChartStates();
};
// Only run on mount/unmount - listeners/cleanup go through refs to avoid
// capturing stale closures.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Apply charts on every render (like componentDidMount + componentDidUpdate calling applyCharts)
useEffect(() => {
applyCharts();
}, [applyCharts]);
// componentDidUpdate equivalent for layout changes
useEffect(() => {
const prevLayout = prevLayoutRef.current;
const prevDashboardId = prevDashboardIdRef.current;
// Update refs for next comparison
prevLayoutRef.current = layout;
prevDashboardIdRef.current = dashboardId;
const currentChartIds = getChartIdsFromLayout(prevLayout);
const nextChartIds = getChartIdsFromLayout(layout);
if (prevDashboardId !== dashboardId) {
// single-page-app navigation check
return;
}
if (currentChartIds.length < nextChartIds.length) {
const newChartIds = nextChartIds.filter(
key => currentChartIds.indexOf(key) === -1,
);
newChartIds.forEach(newChartId =>
actions.addSliceToDashboard(
newChartId,
getLayoutComponentFromChartId(layout, newChartId),
),
);
} else if (currentChartIds.length > nextChartIds.length) {
// remove chart
const removedChartIds = currentChartIds.filter(
key => nextChartIds.indexOf(key) === -1,
);
removedChartIds.forEach(removedChartId =>
actions.removeSliceFromDashboard(removedChartId),
);
}
}, [layout, dashboardId, actions]);
if (context.loading) {
return <Loading />;
} }
return <>{children}</>;
} }
export default Dashboard; export default Dashboard;

View File

@@ -31,9 +31,10 @@ export const getRootLevelTabsComponent = (dashboardLayout: DashboardLayout) => {
}; };
export const shouldFocusTabs = ( export const shouldFocusTabs = (
event: { target: { className: string } }, event: { target: HTMLElement },
container: { contains: (arg0: any) => any }, container: Pick<Node, 'contains'> | null,
) => _menuRef: HTMLDivElement | null,
): boolean =>
// don't focus the tabs when we click on a tab // don't focus the tabs when we click on a tab
event.target.className === 'ant-tabs-nav-wrap' || event.target.className === 'ant-tabs-nav-wrap' ||
container.contains(event.target); (container?.contains(event.target) ?? false);

View File

@@ -16,12 +16,11 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { PureComponent, Fragment } from 'react'; import { Fragment, useCallback, useRef, useState } from 'react';
import { withTheme } from '@emotion/react';
import classNames from 'classnames'; import classNames from 'classnames';
import { addAlpha } from '@superset-ui/core'; import { addAlpha } from '@superset-ui/core';
import { css, styled, type SupersetTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation'; import { t } from '@apache-superset/core/translation';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { EmptyState } from '@superset-ui/core/components'; import { EmptyState } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons'; import { Icons } from '@superset-ui/core/components/Icons';
import { navigateTo } from 'src/utils/navigationUtils'; import { navigateTo } from 'src/utils/navigationUtils';
@@ -48,11 +47,6 @@ export interface DashboardGridProps {
setEditMode?: (editMode: boolean) => void; setEditMode?: (editMode: boolean) => void;
width: number; width: number;
dashboardId?: number; dashboardId?: number;
theme: SupersetTheme;
}
interface DashboardGridState {
isResizing: boolean;
} }
interface DropProps { interface DropProps {
@@ -131,261 +125,235 @@ const GridColumnGuide = styled.div`
`}; `};
`; `;
class DashboardGrid extends PureComponent< function DashboardGrid({
DashboardGridProps, depth,
DashboardGridState editMode,
> { canEdit,
grid: HTMLDivElement | null; gridComponent,
handleComponentDrop,
isComponentVisible,
resizeComponent,
setDirectPathToChild,
setEditMode,
width,
dashboardId,
}: DashboardGridProps) {
const theme = useTheme();
const [isResizing, setIsResizing] = useState(false);
const gridRef = useRef<HTMLDivElement | null>(null);
constructor(props: DashboardGridProps) { const setGridRef = useCallback((ref: HTMLDivElement | null): void => {
super(props); gridRef.current = ref;
this.state = { }, []);
isResizing: false,
};
this.grid = null;
this.handleResizeStart = this.handleResizeStart.bind(this);
this.handleResize = this.handleResize.bind(this);
this.handleResizeStop = this.handleResizeStop.bind(this);
this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
this.setGridRef = this.setGridRef.bind(this);
this.handleChangeTab = this.handleChangeTab.bind(this);
}
getRowGuidePosition(resizeRef: HTMLElement | null): number | null { const handleResizeStart = useCallback((): void => {
if (resizeRef && this.grid) { setIsResizing(true);
return ( }, []);
resizeRef.getBoundingClientRect().bottom -
this.grid.getBoundingClientRect().top -
2
);
}
return null;
}
setGridRef(ref: HTMLDivElement | null): void { const handleResize = useCallback(
this.grid = ref; (
} _event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
_delta: { width: number; height: number },
): void => {
// no-op: resize position tracking not implemented
},
[],
);
handleResizeStart(): void { const handleResizeStop = useCallback(
this.setState(() => ({ (
isResizing: true, _event: MouseEvent | TouchEvent,
})); _direction: string,
} _elementRef: HTMLElement,
delta: { width: number; height: number },
handleResize( id: string,
_event: MouseEvent | TouchEvent, ): void => {
_direction: string, resizeComponent({
_elementRef: HTMLElement, id,
_delta: { width: number; height: number }, width: delta.width,
): void { height: delta.height,
// no-op: resize position is tracked via getRowGuidePosition
}
handleResizeStop(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
delta: { width: number; height: number },
id: string,
): void {
this.props.resizeComponent({
id,
width: delta.width,
height: delta.height,
});
this.setState(() => ({
isResizing: false,
}));
}
handleTopDropTargetDrop(dropResult: DropResult): void {
if (dropResult?.destination) {
this.props.handleComponentDrop({
...dropResult,
destination: {
...dropResult.destination,
// force appending as the first child if top drop target
index: 0,
},
}); });
}
}
handleChangeTab({ pathToTabIndex }: { pathToTabIndex: string[] }): void { setIsResizing(false);
this.props.setDirectPathToChild(pathToTabIndex); },
} [resizeComponent],
);
render() { const handleTopDropTargetDrop = useCallback(
const { (dropResult: DropResult): void => {
gridComponent, if (dropResult?.destination) {
handleComponentDrop, handleComponentDrop({
depth, ...dropResult,
width, destination: {
isComponentVisible, ...dropResult.destination,
editMode, // force appending as the first child if top drop target
canEdit, index: 0,
setEditMode, },
dashboardId, });
theme, }
} = this.props; },
const columnPlusGutterWidth = [handleComponentDrop],
(width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT; );
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE; const handleChangeTab = useCallback(
const { isResizing } = this.state; ({ pathToTabIndex }: { pathToTabIndex: string[] }): void => {
setDirectPathToChild(pathToTabIndex);
},
[setDirectPathToChild],
);
const shouldDisplayEmptyState = gridComponent?.children?.length === 0; const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
const shouldDisplayTopLevelTabEmptyState =
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
const dashboardEmptyState = editMode && ( const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
<EmptyState
title={t('Drag and drop components and charts to the dashboard')}
description={t(
'You can create a new chart or use existing ones from the panel on the right',
)}
size="large"
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
);
const topLevelTabEmptyState = editMode ? ( const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
<EmptyState const shouldDisplayTopLevelTabEmptyState =
title={t('Drag and drop components to this tab')} shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
size="large"
description={t(
`You can create a new chart or use existing ones from the panel on the right`,
)}
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
) : (
<EmptyState
title={t('There are no components added to this tab')}
size="large"
description={
canEdit && t('You can add the components in the edit mode.')
}
buttonText={canEdit ? t('Edit the dashboard') : undefined}
buttonAction={
canEdit
? () => {
setEditMode?.(true);
}
: undefined
}
image="chart.svg"
/>
);
return width < 100 ? null : ( const dashboardEmptyState = editMode && (
<> <EmptyState
{shouldDisplayEmptyState && ( title={t('Drag and drop components and charts to the dashboard')}
<DashboardEmptyStateContainer> description={t(
{shouldDisplayTopLevelTabEmptyState 'You can create a new chart or use existing ones from the panel on the right',
? topLevelTabEmptyState )}
: dashboardEmptyState} size="large"
</DashboardEmptyStateContainer> buttonText={
)} <>
<div className="dashboard-grid" ref={this.setGridRef}> <Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
<GridContent {t('Create a new chart')}
className="grid-content" </>
data-test="grid-content" }
editMode={editMode} buttonAction={() => {
> navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
{/* make the area above components droppable */} newWindow: true,
{editMode && ( });
<Droppable }}
component={gridComponent} image="chart.svg"
depth={depth} />
parentComponent={null} );
index={0}
orientation="column" const topLevelTabEmptyState = editMode ? (
onDrop={this.handleTopDropTargetDrop} <EmptyState
className={classNames({ title={t('Drag and drop components to this tab')}
'empty-droptarget': true, size="large"
'empty-droptarget--full': description={t(
gridComponent?.children?.length === 0, `You can create a new chart or use existing ones from the panel on the right`,
})} )}
editMode buttonText={
dropToChild={gridComponent?.children?.length === 0} <>
> <Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{renderDraggableContent} {t('Create a new chart')}
</Droppable> </>
)} }
{gridComponent?.children?.map((id, index) => ( buttonAction={() => {
<Fragment key={id}> navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
<DashboardComponent newWindow: true,
id={id} });
parentId={gridComponent.id} }}
depth={depth + 1} image="chart.svg"
index={index} />
availableColumnCount={GRID_COLUMN_COUNT} ) : (
columnWidth={columnWidth} <EmptyState
isComponentVisible={isComponentVisible} title={t('There are no components added to this tab')}
onResizeStart={this.handleResizeStart} size="large"
onResize={this.handleResize} description={canEdit && t('You can add the components in the edit mode.')}
onResizeStop={this.handleResizeStop} buttonText={canEdit ? t('Edit the dashboard') : undefined}
onChangeTab={this.handleChangeTab} buttonAction={
canEdit
? () => {
setEditMode?.(true);
}
: undefined
}
image="chart.svg"
/>
);
return width < 100 ? null : (
<>
{shouldDisplayEmptyState && (
<DashboardEmptyStateContainer>
{shouldDisplayTopLevelTabEmptyState
? topLevelTabEmptyState
: dashboardEmptyState}
</DashboardEmptyStateContainer>
)}
<div className="dashboard-grid" ref={setGridRef}>
<GridContent
className="grid-content"
data-test="grid-content"
editMode={editMode}
>
{/* make the area above components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={0}
orientation="column"
onDrop={handleTopDropTargetDrop}
className={classNames({
'empty-droptarget': true,
'empty-droptarget--full': gridComponent?.children?.length === 0,
})}
editMode
dropToChild={gridComponent?.children?.length === 0}
>
{renderDraggableContent}
</Droppable>
)}
{gridComponent?.children?.map((id, index) => (
<Fragment key={id}>
<DashboardComponent
id={id}
parentId={gridComponent.id}
depth={depth + 1}
index={index}
availableColumnCount={GRID_COLUMN_COUNT}
columnWidth={columnWidth}
isComponentVisible={isComponentVisible}
onResizeStart={handleResizeStart}
onResize={handleResize}
onResizeStop={handleResizeStop}
onChangeTab={handleChangeTab}
/>
{/* make the area below components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={index + 1}
orientation="column"
onDrop={handleComponentDrop}
className="empty-droptarget"
editMode
>
{renderDraggableContent}
</Droppable>
)}
</Fragment>
))}
{isResizing &&
Array(GRID_COLUMN_COUNT)
.fill(null)
.map((_, i) => (
<GridColumnGuide
key={`grid-column-${i}`}
className="grid-column-guide"
style={{
left: i * GRID_GUTTER_SIZE + i * columnWidth,
width: columnWidth,
}}
/> />
{/* make the area below components droppable */} ))}
{editMode && ( </GridContent>
<Droppable </div>
component={gridComponent} </>
depth={depth} );
parentComponent={null}
index={index + 1}
orientation="column"
onDrop={handleComponentDrop}
className="empty-droptarget"
editMode
>
{renderDraggableContent}
</Droppable>
)}
</Fragment>
))}
{isResizing &&
Array(GRID_COLUMN_COUNT)
.fill(null)
.map((_, i) => (
<GridColumnGuide
key={`grid-column-${i}`}
className="grid-column-guide"
style={{
left: i * GRID_GUTTER_SIZE + i * columnWidth,
width: columnWidth,
}}
/>
))}
</GridContent>
</div>
</>
);
}
} }
export default withTheme(DashboardGrid); export default DashboardGrid;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { Component } from 'react'; import { useCallback } from 'react';
import { t } from '@apache-superset/core/translation'; import { t } from '@apache-superset/core/translation';
import { Tooltip, PublishedLabel } from '@superset-ui/core/components'; import { Tooltip, PublishedLabel } from '@superset-ui/core/components';
import { HeaderProps, HeaderDropdownProps } from '../Header/types'; import { HeaderProps, HeaderDropdownProps } from '../Header/types';
@@ -43,70 +43,64 @@ const publishedTooltip = t(
'This dashboard is published. Click to make it a draft.', 'This dashboard is published. Click to make it a draft.',
); );
export default class PublishedStatus extends Component<DashboardPublishedStatusType> { export default function PublishedStatus({
constructor(props: DashboardPublishedStatusType) { dashboardId,
super(props); userCanEdit,
this.togglePublished = this.togglePublished.bind(this); userCanSave,
} isPublished,
savePublished,
}: DashboardPublishedStatusType) {
const togglePublished = useCallback(() => {
savePublished(dashboardId, !isPublished);
}, [dashboardId, isPublished, savePublished]);
togglePublished() { // Show everybody the draft badge
this.props.savePublished(this.props.dashboardId, !this.props.isPublished); if (!isPublished) {
} // if they can edit the dash, make the badge a button
if (userCanEdit && userCanSave) {
render() {
const { isPublished, userCanEdit, userCanSave } = this.props;
// Show everybody the draft badge
if (!isPublished) {
// if they can edit the dash, make the badge a button
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftButtonTooltip}
>
<div>
<PublishedLabel
isPublished={isPublished}
onClick={this.togglePublished}
/>
</div>
</Tooltip>
);
}
return ( return (
<Tooltip <Tooltip
id="unpublished-dashboard-tooltip" id="unpublished-dashboard-tooltip"
placement="bottom" placement="bottom"
title={draftDivTooltip} title={draftButtonTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} />
</div>
</Tooltip>
);
}
// Show the published badge for the owner of the dashboard to toggle
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="published-dashboard-tooltip"
placement="bottom"
title={publishedTooltip}
> >
<div> <div>
<PublishedLabel <PublishedLabel
isPublished={isPublished} isPublished={isPublished}
onClick={this.togglePublished} onClick={togglePublished}
/> />
</div> </div>
</Tooltip> </Tooltip>
); );
} }
return (
// Don't show anything if one doesn't own the dashboard and it is published <Tooltip
return null; id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftDivTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} />
</div>
</Tooltip>
);
} }
// Show the published badge for the owner of the dashboard to toggle
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="published-dashboard-tooltip"
placement="bottom"
title={publishedTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} onClick={togglePublished} />
</div>
</Tooltip>
);
}
// Don't show anything if one doesn't own the dashboard and it is published
return null;
} }

View File

@@ -17,13 +17,13 @@
* under the License. * under the License.
*/ */
/* eslint-env browser */ /* eslint-env browser */
import { Component } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window'; import { FixedSizeList as List } from 'react-window';
// @ts-expect-error // @ts-expect-error
import { createFilter } from 'react-search-input'; import { createFilter } from 'react-search-input';
import { t } from '@apache-superset/core/translation'; import { t } from '@apache-superset/core/translation';
import { styled, css } from '@apache-superset/core/theme'; import { styled, css, useTheme } from '@apache-superset/core/theme';
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -49,7 +49,6 @@ import {
import { debounce, pickBy } from 'lodash'; import { debounce, pickBy } from 'lodash';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { Slice } from 'src/dashboard/types'; import { Slice } from 'src/dashboard/types';
import { withTheme, Theme } from '@emotion/react';
import { navigateTo } from 'src/utils/navigationUtils'; import { navigateTo } from 'src/utils/navigationUtils';
import type { ConnectDragSource } from 'react-dnd'; import type { ConnectDragSource } from 'react-dnd';
import AddSliceCard from './AddSliceCard'; import AddSliceCard from './AddSliceCard';
@@ -58,7 +57,6 @@ import { DragDroppable } from './dnd/DragDroppable';
import { datasetLabelLower } from 'src/features/semanticLayers/label'; import { datasetLabelLower } from 'src/features/semanticLayers/label';
export type SliceAdderProps = { export type SliceAdderProps = {
theme: Theme;
fetchSlices: ( fetchSlices: (
userId?: number, userId?: number,
filter_value?: string, filter_value?: string,
@@ -77,14 +75,6 @@ export type SliceAdderProps = {
dashboardId: number; dashboardId: number;
}; };
type SliceAdderState = {
filteredSlices: Slice[];
searchTerm: string;
sortBy: keyof Slice;
selectedSliceIdsSet: Set<number>;
showOnlyMyCharts: boolean;
};
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name']; const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
const KEYS_TO_SORT = { const KEYS_TO_SORT = {
slice_name: t('name'), slice_name: t('name'),
@@ -174,295 +164,308 @@ function getFilteredSortedSlices(
.filter(createFilter(searchTerm, KEYS_TO_FILTERS)) .filter(createFilter(searchTerm, KEYS_TO_FILTERS))
.sort(sortByComparator(sortBy)); .sort(sortByComparator(sortBy));
} }
class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
private slicesRequest?: AbortController | Promise<void>;
static defaultProps = { function SliceAdder({
selectedSliceIds: [], fetchSlices,
editMode: false, updateSlices,
errorMessage: '', isLoading,
}; slices,
errorMessage = '',
userId,
selectedSliceIds = [],
editMode = false,
dashboardId,
}: SliceAdderProps) {
const theme = useTheme();
const slicesRequestRef = useRef<AbortController | Promise<void>>();
constructor(props: SliceAdderProps) { const [searchTerm, setSearchTerm] = useState('');
super(props); const [sortBy, setSortBy] = useState<keyof Slice>(DEFAULT_SORT_KEY);
this.state = { const [selectedSliceIdsSet, setSelectedSliceIdsSet] = useState(
filteredSlices: [], () => new Set(selectedSliceIds),
searchTerm: '', );
sortBy: DEFAULT_SORT_KEY,
selectedSliceIdsSet: new Set(props.selectedSliceIds),
showOnlyMyCharts: getItem(
LocalStorageKeys.DashboardEditorShowOnlyMyCharts,
true,
),
};
this.rowRenderer = this.rowRenderer.bind(this);
this.searchUpdated = this.searchUpdated.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.userIdForFetch = this.userIdForFetch.bind(this);
this.onShowOnlyMyCharts = this.onShowOnlyMyCharts.bind(this);
}
userIdForFetch() { // Refs to track latest values for cleanup effect
return this.state.showOnlyMyCharts ? this.props.userId : undefined; const latestSlicesRef = useRef(slices);
} const latestSelectedSliceIdsSetRef = useRef(selectedSliceIdsSet);
const [showOnlyMyCharts, setShowOnlyMyCharts] = useState(() =>
getItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, true),
);
componentDidMount() { // Keep refs updated with latest values
this.slicesRequest = this.props.fetchSlices( useEffect(() => {
this.userIdForFetch(), latestSlicesRef.current = slices;
'', }, [slices]);
this.state.sortBy,
);
}
componentDidUpdate(prevProps: SliceAdderProps) { useEffect(() => {
const nextState: SliceAdderState = {} as SliceAdderState; latestSelectedSliceIdsSetRef.current = selectedSliceIdsSet;
if (this.props.lastUpdated !== prevProps.lastUpdated) { }, [selectedSliceIdsSet]);
nextState.filteredSlices = getFilteredSortedSlices(
this.props.slices,
this.state.searchTerm,
this.state.sortBy,
this.state.showOnlyMyCharts,
this.props.userId,
);
}
if (prevProps.selectedSliceIds !== this.props.selectedSliceIds) { const filteredSlices = useMemo(
nextState.selectedSliceIdsSet = new Set(this.props.selectedSliceIds); () =>
} getFilteredSortedSlices(
slices,
if (Object.keys(nextState).length) {
this.setState(nextState);
}
}
componentWillUnmount() {
// Clears the redux store keeping only selected items
const selectedSlices = pickBy(this.props.slices, (value: Slice) =>
this.state.selectedSliceIdsSet.has(value.slice_id),
);
this.props.updateSlices(selectedSlices);
if (this.slicesRequest instanceof AbortController) {
this.slicesRequest.abort();
}
}
handleChange = debounce(value => {
this.searchUpdated(value);
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
value,
this.state.sortBy,
);
}, 300);
searchUpdated(searchTerm: string) {
this.setState(prevState => ({
searchTerm,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
searchTerm, searchTerm,
prevState.sortBy,
prevState.showOnlyMyCharts,
this.props.userId,
),
}));
}
handleSelect(sortBy: keyof Slice) {
this.setState(prevState => ({
sortBy,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
prevState.searchTerm,
sortBy, sortBy,
prevState.showOnlyMyCharts,
this.props.userId,
),
}));
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
this.state.searchTerm,
sortBy,
);
}
rowRenderer({ index, style }: { index: number; style: React.CSSProperties }) {
const { filteredSlices, selectedSliceIdsSet } = this.state;
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={this.props.editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
}
onShowOnlyMyCharts = (showOnlyMyCharts: boolean) => {
if (!showOnlyMyCharts) {
this.slicesRequest = this.props.fetchSlices(
undefined,
this.state.searchTerm,
this.state.sortBy,
);
}
this.setState(prevState => ({
showOnlyMyCharts,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
prevState.searchTerm,
prevState.sortBy,
showOnlyMyCharts, showOnlyMyCharts,
this.props.userId, userId,
), ),
})); [slices, searchTerm, sortBy, showOnlyMyCharts, userId],
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, showOnlyMyCharts); );
};
render() { const userIdForFetch = useCallback(
const { theme } = this.props; () => (showOnlyMyCharts ? userId : undefined),
return ( [showOnlyMyCharts, userId],
<div );
css={css`
height: 100%; // Refs so the debounced search reads the latest sortBy/userIdForFetch at
display: flex; // fire time without recreating the debounce (which would drop a pending,
flex-direction: column; // armed-but-not-yet-fired search when sortBy/showOnlyMyCharts change).
button > span > :first-of-type { const sortByRef = useRef(sortBy);
margin-right: 0; const userIdForFetchRef = useRef(userIdForFetch);
useEffect(() => {
sortByRef.current = sortBy;
}, [sortBy]);
useEffect(() => {
userIdForFetchRef.current = userIdForFetch;
}, [userIdForFetch]);
// componentDidMount
useEffect(() => {
slicesRequestRef.current = fetchSlices(userIdForFetch(), '', sortBy);
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update selectedSliceIdsSet when selectedSliceIds prop changes
useEffect(() => {
setSelectedSliceIdsSet(new Set(selectedSliceIds));
}, [selectedSliceIds]);
// componentWillUnmount
useEffect(
() => () => {
// Clears the redux store keeping only selected items
// Use refs to get latest values on unmount
const selectedSlices = pickBy(latestSlicesRef.current, (value: Slice) =>
latestSelectedSliceIdsSetRef.current.has(value.slice_id),
);
updateSlices(selectedSlices);
if (slicesRequestRef.current instanceof AbortController) {
slicesRequestRef.current.abort();
}
},
[updateSlices],
);
const searchUpdated = useCallback((term: string) => {
setSearchTerm(term);
}, []);
const fetchSlicesRef = useRef(fetchSlices);
useEffect(() => {
fetchSlicesRef.current = fetchSlices;
}, [fetchSlices]);
// Create the debounce once (stable identity) so a pending search isn't
// dropped when sortBy/userIdForFetch change mid-typing. The debounced
// function reads the latest values from refs at fire time.
const handleChange = useMemo(
() =>
debounce((value: string) => {
searchUpdated(value);
slicesRequestRef.current = fetchSlicesRef.current(
userIdForFetchRef.current(),
value,
sortByRef.current,
);
}, 300),
[searchUpdated],
);
useEffect(
() => () => {
handleChange.cancel();
},
[handleChange],
);
const handleSelect = useCallback(
(newSortBy: keyof Slice) => {
setSortBy(newSortBy);
slicesRequestRef.current = fetchSlices(
userIdForFetch(),
searchTerm,
newSortBy,
);
},
[fetchSlices, searchTerm, userIdForFetch],
);
const onShowOnlyMyCharts = useCallback(
(checked: boolean) => {
if (!checked) {
slicesRequestRef.current = fetchSlices(undefined, searchTerm, sortBy);
}
setShowOnlyMyCharts(checked);
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, checked);
},
[fetchSlices, searchTerm, sortBy],
);
const rowRenderer = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
},
[filteredSlices, selectedSliceIdsSet, editMode],
);
return (
<div
css={css`
height: 100%;
display: flex;
flex-direction: column;
button > span > :first-of-type {
margin-right: 0;
}
`}
>
<NewChartButtonContainer>
<NewChartButton
buttonStyle="link"
buttonSize="xsmall"
icon={
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
} }
onClick={() =>
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
})
}
>
{t('Create new chart')}
</NewChartButton>
</NewChartButtonContainer>
<Controls>
<Input
placeholder={
showOnlyMyCharts ? t('Filter your charts') : t('Filter charts')
}
className="search-input"
onChange={ev => handleChange(ev.target.value)}
data-test="dashboard-charts-filter-search-input"
/>
<StyledSelect
id="slice-adder-sortby"
value={sortBy}
onChange={handleSelect}
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
label: t('Sort by %s', label),
value: key,
}))}
placeholder={t('Sort by')}
/>
</Controls>
<div
css={themeObj => css`
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: ${themeObj.sizeUnit}px;
padding: 0 ${themeObj.sizeUnit * 3}px ${themeObj.sizeUnit * 4}px
${themeObj.sizeUnit * 3}px;
`} `}
> >
<NewChartButtonContainer> <Checkbox
<NewChartButton onChange={e => onShowOnlyMyCharts(e.target.checked)}
buttonStyle="link" checked={showOnlyMyCharts}
buttonSize="xsmall" />
icon={ {t('Show only my charts')}
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} /> <InfoTooltip
} placement="top"
onClick={() => tooltip={t(
navigateTo(`/chart/add?dashboard_id=${this.props.dashboardId}`, { `You can choose to display all charts that you have access to or only the ones you own.
newWindow: true, Your filter selection will be saved and remain active until you choose to change it.`,
}) )}
} />
> </div>
{t('Create new chart')} {isLoading && <Loading />}
</NewChartButton> {!isLoading && filteredSlices.length > 0 && (
</NewChartButtonContainer> <ChartList>
<Controls> <AutoSizer>
<Input {({ height, width }: { height: number; width: number }) => (
placeholder={ <List
this.state.showOnlyMyCharts width={width}
? t('Filter your charts') height={height}
: t('Filter charts') itemCount={filteredSlices.length}
} itemSize={DEFAULT_CELL_HEIGHT}
className="search-input" itemKey={index => filteredSlices[index].slice_id}
onChange={ev => this.handleChange(ev.target.value)} >
data-test="dashboard-charts-filter-search-input" {rowRenderer}
/> </List>
<StyledSelect )}
id="slice-adder-sortby" </AutoSizer>
value={this.state.sortBy} </ChartList>
onChange={this.handleSelect} )}
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({ {errorMessage && (
label: t('Sort by %s', label),
value: key,
}))}
placeholder={t('Sort by')}
/>
</Controls>
<div <div
css={theme => css` css={css`
display: flex; padding: 16px;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: ${theme.sizeUnit}px;
padding: 0 ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px
${theme.sizeUnit * 3}px;
`} `}
> >
<Checkbox {errorMessage}
onChange={e => this.onShowOnlyMyCharts(e.target.checked)}
checked={this.state.showOnlyMyCharts}
/>
{t('Show only my charts')}
<InfoTooltip
placement="top"
tooltip={t(
`You can choose to display all charts that you have access to or only the ones you own.
Your filter selection will be saved and remain active until you choose to change it.`,
)}
/>
</div> </div>
{this.props.isLoading && <Loading />} )}
{!this.props.isLoading && this.state.filteredSlices.length > 0 && ( {/* Drag preview is just a single fixed-position element */}
<ChartList> <AddSliceDragPreview slices={filteredSlices} />
<AutoSizer> </div>
{({ height, width }: { height: number; width: number }) => ( );
<List
width={width}
height={height}
itemCount={this.state.filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
itemKey={index => this.state.filteredSlices[index].slice_id}
>
{this.rowRenderer}
</List>
)}
</AutoSizer>
</ChartList>
)}
{this.props.errorMessage && (
<div
css={css`
padding: 16px;
`}
>
{this.props.errorMessage}
</div>
)}
{/* Drag preview is just a single fixed-position element */}
<AddSliceDragPreview slices={this.state.filteredSlices} />
</div>
);
}
} }
export default withTheme(SliceAdder); export default SliceAdder;

View File

@@ -43,6 +43,40 @@ test('triggers onRedo', () => {
expect(onRedo).toHaveBeenCalledTimes(1); expect(onRedo).toHaveBeenCalledTimes(1);
}); });
test('triggers onRedo with Ctrl+Shift+Z', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
fireEvent.keyDown(document.body, {
key: 'z',
keyCode: 90,
ctrlKey: true,
shiftKey: true,
});
expect(onRedo).toHaveBeenCalledTimes(1);
expect(onUndo).not.toHaveBeenCalled();
});
test('triggers onUndo via keyCode fallback for non-Latin layouts', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
// event.key is a non-'z' glyph (e.g. non-Latin layout), but code is KeyZ
fireEvent.keyDown(document.body, { key: 'я', code: 'KeyZ', ctrlKey: true });
expect(onUndo).toHaveBeenCalledTimes(1);
expect(onRedo).not.toHaveBeenCalled();
});
test('triggers onRedo via keyCode fallback for non-Latin layouts', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
// event.key is a non-'y' glyph, but code is KeyY
fireEvent.keyDown(document.body, { key: 'н', code: 'KeyY', ctrlKey: true });
expect(onRedo).toHaveBeenCalledTimes(1);
expect(onUndo).not.toHaveBeenCalled();
});
test('does not trigger when it is another key', () => { test('does not trigger when it is another key', () => {
const onUndo = jest.fn(); const onUndo = jest.fn();
const onRedo = jest.fn(); const onRedo = jest.fn();

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { PureComponent } from 'react'; import { useCallback, useEffect } from 'react';
import { HeaderProps } from '../Header/types'; import { HeaderProps } from '../Header/types';
type UndoRedoKeyListenersProps = { type UndoRedoKeyListenersProps = {
@@ -24,43 +24,43 @@ type UndoRedoKeyListenersProps = {
onRedo: HeaderProps['onRedo']; onRedo: HeaderProps['onRedo'];
}; };
class UndoRedoKeyListeners extends PureComponent<UndoRedoKeyListenersProps> { function UndoRedoKeyListeners({ onUndo, onRedo }: UndoRedoKeyListenersProps) {
constructor(props: UndoRedoKeyListenersProps) { const handleKeydown = useCallback(
super(props); (event: KeyboardEvent) => {
this.handleKeydown = this.handleKeydown.bind(this); const controlOrCommand = event.ctrlKey || event.metaKey;
} if (controlOrCommand) {
const key = event.key.toLowerCase();
// Fall back to event.code (the physical key) so undo/redo still work on
// non-Latin keyboard layouts where event.key is a different glyph.
const isZ = key === 'z' || event.code === 'KeyZ';
const isY = key === 'y' || event.code === 'KeyY';
const isUndo = isZ && !event.shiftKey;
const isRedo = isY || (isZ && event.shiftKey);
const isEditingMarkdown = document?.querySelector(
'.dashboard-markdown--editing',
);
const isEditingTitle = document?.querySelector(
'.editable-title--editing',
);
componentDidMount() { if (!isEditingMarkdown && !isEditingTitle && (isUndo || isRedo)) {
document.addEventListener('keydown', this.handleKeydown); event.preventDefault();
} const func = isUndo ? onUndo : onRedo;
func();
componentWillUnmount() { }
document.removeEventListener('keydown', this.handleKeydown);
}
handleKeydown(event: KeyboardEvent) {
const controlOrCommand = event.ctrlKey || event.metaKey;
if (controlOrCommand) {
const isZChar = event.key === 'z' || event.keyCode === 90;
const isYChar = event.key === 'y' || event.keyCode === 89;
const isEditingMarkdown = document?.querySelector(
'.dashboard-markdown--editing',
);
const isEditingTitle = document?.querySelector(
'.editable-title--editing',
);
if (!isEditingMarkdown && !isEditingTitle && (isZChar || isYChar)) {
event.preventDefault();
const func = isZChar ? this.props.onUndo : this.props.onRedo;
func();
} }
} },
} [onUndo, onRedo],
);
render() { useEffect(() => {
return null; document.addEventListener('keydown', handleKeydown);
} return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [handleKeydown]);
return null;
} }
export default UndoRedoKeyListeners; export default UndoRedoKeyListeners;

View File

@@ -33,7 +33,6 @@ import {
} from 'react-dnd'; } from 'react-dnd';
import cx from 'classnames'; import cx from 'classnames';
import { css, styled } from '@apache-superset/core/theme'; import { css, styled } from '@apache-superset/core/theme';
import { dragConfig, dropConfig } from './dragDroppableConfig'; import { dragConfig, dropConfig } from './dragDroppableConfig';
import type { DragDroppableProps as BaseDragDroppableProps } from './dragDroppableConfig'; import type { DragDroppableProps as BaseDragDroppableProps } from './dragDroppableConfig';
import { DROP_FORBIDDEN } from '../../util/getDropPosition'; import { DROP_FORBIDDEN } from '../../util/getDropPosition';
@@ -122,15 +121,22 @@ const DragDroppableStyles = styled.div`
} }
`}; `};
`; `;
/**
* Note: This component remains a class component because it is tightly integrated
* with react-dnd's class-based HOC system (DragSource/DropTarget). The HOCs
* access component instance properties directly (mounted, ref, props, setState)
* in the hover/drop callbacks defined in dragDroppableConfig.ts.
*
* Converting to a function component would require migrating to react-dnd's
* hooks API (useDrag/useDrop), which would be a more extensive refactor.
*/
// export unwrapped component for testing // export unwrapped component for testing
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- react-dnd class-based HOC requires class component instance properties
export class UnwrappedDragDroppable extends PureComponent< export class UnwrappedDragDroppable extends PureComponent<
DragDroppableAllProps, DragDroppableAllProps,
DragDroppableState DragDroppableState
> { > {
mounted: boolean;
ref: HTMLDivElement | null;
static defaultProps = { static defaultProps = {
className: null, className: null,
style: null, style: null,
@@ -152,6 +158,10 @@ export class UnwrappedDragDroppable extends PureComponent<
dragPreviewRef() {}, dragPreviewRef() {},
}; };
mounted: boolean;
ref: HTMLDivElement | null;
constructor(props: DragDroppableAllProps) { constructor(props: DragDroppableAllProps) {
super(props); super(props);
this.state = { this.state = {
@@ -283,7 +293,6 @@ export class UnwrappedDragDroppable extends PureComponent<
// react-dnd's DragSource/DropTarget HOC types don't play well with // react-dnd's DragSource/DropTarget HOC types don't play well with
// class components using spread config tuples, so we use type assertions here // class components using spread config tuples, so we use type assertions here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const DragDroppableAsAny = const DragDroppableAsAny =
UnwrappedDragDroppable as unknown as ReactComponentType< UnwrappedDragDroppable as unknown as ReactComponentType<
Record<string, unknown> Record<string, unknown>

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { createRef, PureComponent } from 'react'; import { useRef, useCallback } from 'react';
import { styled } from '@apache-superset/core/theme'; import { styled } from '@apache-superset/core/theme';
import { import {
ModalTrigger, ModalTrigger,
@@ -33,39 +33,29 @@ const FilterScopeModalBody = styled.div(({ theme: { sizeUnit } }) => ({
paddingBottom: sizeUnit * 3, paddingBottom: sizeUnit * 3,
})); }));
export default class FilterScopeModal extends PureComponent< export default function FilterScopeModal({
FilterScopeModalProps, triggerNode,
{} }: FilterScopeModalProps) {
> { const modalRef = useRef<ModalTriggerRef['current']>(null);
modal: ModalTriggerRef;
constructor(props: FilterScopeModalProps) { const handleCloseModal = useCallback((): void => {
super(props); modalRef.current?.close?.();
}, []);
this.modal = createRef() as ModalTriggerRef; const filterScopeProps = {
this.handleCloseModal = this.handleCloseModal.bind(this); onCloseModal: handleCloseModal,
} };
handleCloseModal(): void { return (
this?.modal?.current?.close?.(); <ModalTrigger
} ref={modalRef}
triggerNode={triggerNode}
render() { modalBody={
const filterScopeProps = { <FilterScopeModalBody>
onCloseModal: this.handleCloseModal, <FilterScope {...filterScopeProps} />
}; </FilterScopeModalBody>
}
return ( width="80%"
<ModalTrigger />
ref={this.modal} );
triggerNode={this.props.triggerNode}
modalBody={
<FilterScopeModalBody>
<FilterScope {...filterScopeProps} />
</FilterScopeModalBody>
}
width="80%"
/>
);
}
} }

View File

@@ -0,0 +1,265 @@
/**
* 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 {
cleanup,
render,
screen,
userEvent,
} from 'spec/helpers/testing-library';
import FilterScopeSelector from './FilterScopeSelector';
import type { DashboardLayout } from 'src/dashboard/types';
// --- Mock child components ---
jest.mock('./FilterFieldTree', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => (
<div data-test="filter-field-tree">
FilterFieldTree (checked={String(props.checked)})
</div>
),
}));
jest.mock('./FilterScopeTree', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => (
<div data-test="filter-scope-tree">
FilterScopeTree (checked={String(props.checked)})
</div>
),
}));
// --- Mock utility functions ---
jest.mock('src/dashboard/util/getFilterFieldNodesTree', () => ({
__esModule: true,
default: jest.fn(() => [
{
value: 'ALL_FILTERS_ROOT',
label: 'All filters',
children: [
{
value: 1,
label: 'Filter A',
children: [
{ value: '1_column_b', label: 'Filter B' },
{ value: '1_column_c', label: 'Filter C' },
],
},
],
},
]),
}));
jest.mock('src/dashboard/util/getFilterScopeNodesTree', () => ({
__esModule: true,
default: jest.fn(() => [
{
value: 'ROOT_ID',
label: 'All charts',
children: [{ value: 2, label: 'Chart A' }],
},
]),
}));
jest.mock('src/dashboard/util/getFilterScopeParentNodes', () => ({
__esModule: true,
default: jest.fn(() => ['ROOT_ID']),
}));
jest.mock('src/dashboard/util/buildFilterScopeTreeEntry', () => ({
__esModule: true,
default: jest.fn(() => ({})),
}));
jest.mock('src/dashboard/util/getKeyForFilterScopeTree', () => ({
__esModule: true,
default: jest.fn(() => '1_column_b'),
}));
jest.mock('src/dashboard/util/getSelectedChartIdForFilterScopeTree', () => ({
__esModule: true,
default: jest.fn(() => 1),
}));
jest.mock('src/dashboard/util/getFilterScopeFromNodesTree', () => ({
__esModule: true,
default: jest.fn(() => ({ scope: ['ROOT_ID'], immune: [] })),
}));
jest.mock('src/dashboard/util/getRevertedFilterScope', () => ({
__esModule: true,
default: jest.fn(() => ({})),
}));
jest.mock('src/dashboard/util/activeDashboardFilters', () => ({
getChartIdsInFilterScope: jest.fn(() => [2, 3]),
}));
afterEach(() => {
cleanup();
jest.clearAllMocks();
});
const mockDashboardFilters = {
1: {
chartId: 1,
componentId: 'component-1',
filterName: 'Filter A',
datasourceId: 'ds-1',
directPathToFilter: ['ROOT_ID', 'GRID', 'CHART_1'],
isDateFilter: false,
isInstantFilter: false,
columns: { column_b: undefined, column_c: undefined },
labels: { column_b: 'Filter B', column_c: 'Filter C' },
scopes: {
column_b: { immune: [], scope: ['ROOT_ID'] },
column_c: { immune: [], scope: ['ROOT_ID'] },
},
},
};
const mockLayout: DashboardLayout = {
ROOT_ID: { children: ['GRID'], id: 'ROOT_ID', type: 'ROOT' },
GRID: {
children: ['CHART_1', 'CHART_2'],
id: 'GRID',
type: 'GRID',
parents: ['ROOT_ID'],
},
CHART_1: {
meta: { chartId: 1, sliceName: 'Chart 1' },
children: [],
id: 'CHART_1',
type: 'CHART',
parents: ['ROOT_ID', 'GRID'],
},
CHART_2: {
meta: { chartId: 2, sliceName: 'Chart 2' },
children: [],
id: 'CHART_2',
type: 'CHART',
parents: ['ROOT_ID', 'GRID'],
},
} as unknown as DashboardLayout;
const defaultProps = {
dashboardFilters: mockDashboardFilters,
layout: mockLayout,
updateDashboardFiltersScope: jest.fn(),
setUnsavedChanges: jest.fn(),
onCloseModal: jest.fn(),
};
test('renders the header, filter field panel, and scope panel', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByText('Configure filter scopes')).toBeInTheDocument();
expect(screen.getByTestId('filter-field-tree')).toBeInTheDocument();
expect(screen.getByTestId('filter-scope-tree')).toBeInTheDocument();
});
test('renders the search input with correct placeholder', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveAttribute('type', 'text');
});
test('renders Close and Save buttons when filters exist', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
test('renders only Close button and a warning when no filters exist', () => {
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
useRedux: true,
});
expect(
screen.getByText('There are no filters in this dashboard.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Save' }),
).not.toBeInTheDocument();
});
test('does not render FilterFieldTree or FilterScopeTree when no filters exist', () => {
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
useRedux: true,
});
expect(screen.queryByTestId('filter-field-tree')).not.toBeInTheDocument();
expect(screen.queryByTestId('filter-scope-tree')).not.toBeInTheDocument();
});
test('calls onCloseModal when Close button is clicked', () => {
const onCloseModal = jest.fn();
render(
<FilterScopeSelector {...defaultProps} onCloseModal={onCloseModal} />,
{ useRedux: true },
);
userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onCloseModal).toHaveBeenCalledTimes(1);
});
test('calls updateDashboardFiltersScope, setUnsavedChanges, and onCloseModal when Save is clicked', () => {
const updateDashboardFiltersScope = jest.fn();
const setUnsavedChanges = jest.fn();
const onCloseModal = jest.fn();
render(
<FilterScopeSelector
{...defaultProps}
updateDashboardFiltersScope={updateDashboardFiltersScope}
setUnsavedChanges={setUnsavedChanges}
onCloseModal={onCloseModal}
/>,
{ useRedux: true },
);
userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(updateDashboardFiltersScope).toHaveBeenCalledTimes(1);
expect(setUnsavedChanges).toHaveBeenCalledWith(true);
expect(onCloseModal).toHaveBeenCalledTimes(1);
});
test('renders the editing filters name section with "Editing 1 filter:" label', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByText('Editing 1 filter:')).toBeInTheDocument();
// The active filter label should appear (column_b maps to "Filter B")
expect(screen.getByText('Filter B')).toBeInTheDocument();
});
test('updates search text when typing in the search input', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
const searchInput = screen.getByPlaceholderText('Search...');
userEvent.type(searchInput, 'Chart');
expect(searchInput).toHaveValue('Chart');
});

View File

@@ -16,12 +16,17 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { PureComponent, ChangeEvent, type ReactElement } from 'react'; import {
useState,
useCallback,
useMemo,
ChangeEvent,
type ReactElement,
} from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { Button, Input } from '@superset-ui/core/components'; import { Button, Input } from '@superset-ui/core/components';
import { css, styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation'; import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import buildFilterScopeTreeEntry from 'src/dashboard/util/buildFilterScopeTreeEntry'; import buildFilterScopeTreeEntry from 'src/dashboard/util/buildFilterScopeTreeEntry';
import getFilterScopeNodesTree from 'src/dashboard/util/getFilterScopeNodesTree'; import getFilterScopeNodesTree from 'src/dashboard/util/getFilterScopeNodesTree';
import getFilterFieldNodesTree from 'src/dashboard/util/getFilterFieldNodesTree'; import getFilterFieldNodesTree from 'src/dashboard/util/getFilterFieldNodesTree';
@@ -90,30 +95,6 @@ export interface FilterScopeSelectorProps {
onCloseModal: () => void; onCloseModal: () => void;
} }
interface FilterScopeSelectorStateWithSelector {
showSelector: true;
activeFilterField: string | null;
searchText: string;
filterScopeMap: FilterScopeMap;
filterFieldNodes: FilterFieldNode[];
checkedFilterFields: string[];
expandedFilterIds: (string | number)[];
}
interface FilterScopeSelectorStateWithoutSelector {
showSelector: false;
activeFilterField?: undefined;
searchText?: undefined;
filterScopeMap?: undefined;
filterFieldNodes?: undefined;
checkedFilterFields?: undefined;
expandedFilterIds?: undefined;
}
type FilterScopeSelectorState =
| FilterScopeSelectorStateWithSelector
| FilterScopeSelectorStateWithoutSelector;
const ScopeContainer = styled.div` const ScopeContainer = styled.div`
${({ theme }) => css` ${({ theme }) => css`
display: flex; display: flex;
@@ -389,271 +370,358 @@ const ActionsContainer = styled.div`
`} `}
`; `;
export default class FilterScopeSelector extends PureComponent< function initializeState(
FilterScopeSelectorProps, dashboardFilters: Record<number, DashboardFilter>,
FilterScopeSelectorState layout: DashboardLayout,
> { ) {
allfilterFields: string[]; if (Object.keys(dashboardFilters).length === 0) {
return {
showSelector: false as const,
allFilterFields: [] as string[],
defaultFilterKey: '',
};
}
defaultFilterKey: string; // display filter fields in tree structure
const filterFieldNodes = getFilterFieldNodesTree({
dashboardFilters,
});
// filterFieldNodes root node is dashboard_root component,
// so that we can offer a select/deselect all link
const filtersNodes = filterFieldNodes[0].children ?? [];
const allFilterFields: string[] = [];
filtersNodes.forEach(({ children }) => {
(children ?? []).forEach(child => {
allFilterFields.push(String(child.value));
});
});
const defaultFilterKey = String(filtersNodes[0]?.children?.[0]?.value ?? '');
constructor(props: FilterScopeSelectorProps) { // build FilterScopeTree object for each filterKey
super(props); const filterScopeMap: FilterScopeMap = Object.values(
dashboardFilters,
this.allfilterFields = []; ).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
this.defaultFilterKey = ''; const filterScopeByChartId = Object.keys(columns).reduce<FilterScopeMap>(
(mapByChartId, columnName) => {
const { dashboardFilters, layout } = props; const filterKey = getDashboardFilterKey({
chartId: String(filterId),
if (Object.keys(dashboardFilters).length > 0) { column: columnName,
// display filter fields in tree structure
const filterFieldNodes = getFilterFieldNodesTree({
dashboardFilters,
});
// filterFieldNodes root node is dashboard_root component,
// so that we can offer a select/deselect all link
const filtersNodes = filterFieldNodes[0].children ?? [];
this.allfilterFields = [];
filtersNodes.forEach(({ children }) => {
(children ?? []).forEach(child => {
this.allfilterFields.push(String(child.value));
}); });
}); const nodes = getFilterScopeNodesTree({
this.defaultFilterKey = String( components: layout,
filtersNodes[0]?.children?.[0]?.value ?? '', filterFields: [filterKey],
); selectedChartId: filterId,
});
// build FilterScopeTree object for each filterKey const expanded = getFilterScopeParentNodes(nodes, 1);
const filterScopeMap: FilterScopeMap = Object.values( const chartIdsInFilterScope = (
dashboardFilters, getChartIdsInFilterScope({
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => { filterScope: dashboardFilters[filterId].scopes[columnName],
const filterScopeByChartId = Object.keys( }) || []
columns, ).filter((id: number) => id !== filterId);
).reduce<FilterScopeMap>((mapByChartId, columnName) => {
const filterKey = getDashboardFilterKey({
chartId: String(filterId),
column: columnName,
});
const nodes = getFilterScopeNodesTree({
components: layout,
filterFields: [filterKey],
selectedChartId: filterId,
});
const expanded = getFilterScopeParentNodes(nodes, 1);
const chartIdsInFilterScope = (
getChartIdsInFilterScope({
filterScope: dashboardFilters[filterId].scopes[columnName],
}) || []
).filter((id: number) => id !== filterId);
return {
...mapByChartId,
[filterKey]: {
// unfiltered nodes
nodes,
// filtered nodes in display if searchText is not empty
nodesFiltered: [...nodes],
checked: chartIdsInFilterScope,
expanded,
},
};
}, {});
return { return {
...map, ...mapByChartId,
...filterScopeByChartId, [filterKey]: {
// unfiltered nodes
nodes,
// filtered nodes in display if searchText is not empty
nodesFiltered: [...nodes],
checked: chartIdsInFilterScope,
expanded,
},
}; };
}, {});
// initial state: active defaultFilerKey
const { chartId } = getChartIdAndColumnFromFilterKey(
this.defaultFilterKey,
);
const checkedFilterFields: string[] = [];
const activeFilterField = this.defaultFilterKey;
// expand defaultFilterKey in filter field tree
const expandedFilterIds: (string | number)[] = [
ALL_FILTERS_ROOT,
chartId,
];
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField,
filterScopeMap,
layout,
});
this.state = {
showSelector: true,
activeFilterField,
searchText: '',
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
} as FilterScopeMap,
filterFieldNodes,
checkedFilterFields,
expandedFilterIds,
};
} else {
this.state = {
showSelector: false,
};
}
this.filterNodes = this.filterNodes.bind(this);
this.onChangeFilterField = this.onChangeFilterField.bind(this);
this.onCheckFilterScope = this.onCheckFilterScope.bind(this);
this.onExpandFilterScope = this.onExpandFilterScope.bind(this);
this.onSearchInputChange = this.onSearchInputChange.bind(this);
this.onCheckFilterField = this.onCheckFilterField.bind(this);
this.onExpandFilterField = this.onExpandFilterField.bind(this);
this.onClose = this.onClose.bind(this);
this.onSave = this.onSave.bind(this);
}
onCheckFilterScope(checked: (string | number)[] = []): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, filterScopeMap, checkedFilterFields } = state;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const editingList = activeFilterField
? [activeFilterField]
: checkedFilterFields;
const updatedEntry = {
...filterScopeMap[key],
checked,
};
const updatedFilterScopeMap = getRevertedFilterScope({
checked,
filterFields: editingList,
filterScopeMap,
});
this.setState(() => ({
filterScopeMap: {
...filterScopeMap,
...updatedFilterScopeMap,
[key]: updatedEntry,
} as FilterScopeMap,
}));
}
onExpandFilterScope(expanded: string[] = []): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields, filterScopeMap } = state;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const updatedEntry = {
...filterScopeMap[key],
expanded,
};
this.setState(() => ({
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
}, },
})); {},
} );
onCheckFilterField(checkedFilterFields: string[] = []): void { return {
const { layout } = this.props; ...map,
const state = this.state as FilterScopeSelectorStateWithSelector; ...filterScopeByChartId,
const { filterScopeMap } = state; };
const filterScopeTreeEntry = buildFilterScopeTreeEntry({ }, {});
checkedFilterFields,
activeFilterField: undefined,
filterScopeMap,
layout,
});
this.setState(() => ({ // initial state: active defaultFilerKey
activeFilterField: null, const { chartId } = getChartIdAndColumnFromFilterKey(defaultFilterKey);
checkedFilterFields, const checkedFilterFields: string[] = [];
const activeFilterField = defaultFilterKey;
// expand defaultFilterKey in filter field tree
const expandedFilterIds: (string | number)[] = [ALL_FILTERS_ROOT, chartId];
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField,
filterScopeMap,
layout,
});
return {
showSelector: true as const,
allFilterFields,
defaultFilterKey,
initialState: {
activeFilterField,
searchText: '',
filterScopeMap: { filterScopeMap: {
...filterScopeMap, ...filterScopeMap,
...filterScopeTreeEntry, ...filterScopeTreeEntry,
}, } as FilterScopeMap,
})); filterFieldNodes,
}
onExpandFilterField(expandedFilterIds: (string | number)[] = []): void {
this.setState(() => ({
expandedFilterIds,
}));
}
onChangeFilterField(filterField: { value?: string } = {}): void {
const { layout } = this.props;
const nextActiveFilterField = filterField.value;
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
activeFilterField: currentActiveFilterField,
checkedFilterFields, checkedFilterFields,
filterScopeMap, expandedFilterIds,
} = state; },
};
}
// we allow single edit and multiple edit in the same view. export default function FilterScopeSelector({
// if user click on the single filter field, dashboardFilters,
// will show filter scope for the single field. layout,
// if user click on the same filter filed again, updateDashboardFiltersScope,
// will toggle off the single filter field, setUnsavedChanges,
// and allow multi-edit all checked filter fields. onCloseModal,
if (nextActiveFilterField === currentActiveFilterField) { }: FilterScopeSelectorProps): ReactElement {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({ const initialized = useMemo(
() => initializeState(dashboardFilters, layout),
// Only initialize once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const { showSelector, allFilterFields } = initialized;
const [activeFilterField, setActiveFilterField] = useState<string | null>(
() =>
initialized.showSelector
? initialized.initialState.activeFilterField
: null,
);
const [searchText, setSearchText] = useState(() =>
initialized.showSelector ? initialized.initialState.searchText : '',
);
const [filterScopeMap, setFilterScopeMap] = useState<FilterScopeMap>(() =>
initialized.showSelector ? initialized.initialState.filterScopeMap : {},
);
const [filterFieldNodes] = useState<FilterFieldNode[]>(() =>
initialized.showSelector ? initialized.initialState.filterFieldNodes : [],
);
const [checkedFilterFields, setCheckedFilterFields] = useState<string[]>(
() =>
initialized.showSelector
? initialized.initialState.checkedFilterFields
: [],
);
const [expandedFilterIds, setExpandedFilterIds] = useState<
(string | number)[]
>(() =>
initialized.showSelector ? initialized.initialState.expandedFilterIds : [],
);
const filterNodes = useCallback(
(
filtered: FilterScopeTreeNode[] = [],
node: FilterScopeTreeNode = { value: '', label: '' },
currentSearchText: string,
): FilterScopeTreeNode[] => {
const filterNodesRecursive = (
f: FilterScopeTreeNode[],
n: FilterScopeTreeNode,
): FilterScopeTreeNode[] => filterNodes(f, n, currentSearchText);
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
filterNodesRecursive,
[],
);
if (
// Node's label matches the search string
node.label
.toLocaleLowerCase()
.indexOf((currentSearchText ?? '').toLocaleLowerCase()) > -1 ||
// Or a children has a matching node
children.length
) {
filtered.push({ ...node, children });
}
return filtered;
},
[],
);
const filterTree = useCallback(
(currentSearchText: string) => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields, checkedFilterFields,
});
// Reset nodes back to unfiltered state
if (!currentSearchText) {
setFilterScopeMap(prev => ({
...prev,
[key]: {
...prev[key],
nodesFiltered: prev[key].nodes,
},
}));
} else {
setFilterScopeMap(prev => {
const nodesFiltered = prev[key].nodes.reduce<FilterScopeTreeNode[]>(
(filtered, node) => filterNodes(filtered, node, currentSearchText),
[],
);
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
return {
...prev,
[key]: {
...prev[key],
nodesFiltered,
expanded,
},
};
});
}
},
[activeFilterField, checkedFilterFields, filterNodes],
);
const onCheckFilterScope = useCallback(
(checked: (string | number)[] = []): void => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const editingList = activeFilterField
? [activeFilterField]
: checkedFilterFields;
const updatedFilterScopeMap = getRevertedFilterScope({
checked,
filterFields: editingList,
filterScopeMap,
});
setFilterScopeMap({
...filterScopeMap,
...updatedFilterScopeMap,
[key]: {
...filterScopeMap[key],
checked,
},
} as FilterScopeMap);
},
[activeFilterField, checkedFilterFields, filterScopeMap],
);
const onExpandFilterScope = useCallback(
(expanded: string[] = []): void => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
setFilterScopeMap(prev => ({
...prev,
[key]: {
...prev[key],
expanded,
},
}));
},
[activeFilterField, checkedFilterFields],
);
const onCheckFilterField = useCallback(
(newCheckedFilterFields: string[] = []): void => {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields: newCheckedFilterFields,
activeFilterField: undefined, activeFilterField: undefined,
filterScopeMap, filterScopeMap,
layout, layout,
}); });
this.setState({ setActiveFilterField(null);
activeFilterField: null, setCheckedFilterFields(newCheckedFilterFields);
filterScopeMap: { setFilterScopeMap({
...filterScopeMap,
...filterScopeTreeEntry,
});
},
[filterScopeMap, layout],
);
const onExpandFilterField = useCallback(
(newExpandedFilterIds: (string | number)[] = []): void => {
setExpandedFilterIds(newExpandedFilterIds);
},
[],
);
const onChangeFilterField = useCallback(
(filterField: { value?: string } = {}): void => {
const nextActiveFilterField = filterField.value;
// we allow single edit and multiple edit in the same view.
// if user click on the single filter field,
// will show filter scope for the single field.
// if user click on the same filter filed again,
// will toggle off the single filter field,
// and allow multi-edit all checked filter fields.
if (nextActiveFilterField === activeFilterField) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: undefined,
filterScopeMap,
layout,
});
setActiveFilterField(null);
setFilterScopeMap({
...filterScopeMap, ...filterScopeMap,
...filterScopeTreeEntry, ...filterScopeTreeEntry,
} as FilterScopeMap, });
}); } else if (
} else if ( nextActiveFilterField &&
nextActiveFilterField && allFilterFields.includes(nextActiveFilterField)
this.allfilterFields.includes(nextActiveFilterField) ) {
) { const filterScopeTreeEntry = buildFilterScopeTreeEntry({
const filterScopeTreeEntry = buildFilterScopeTreeEntry({ checkedFilterFields,
checkedFilterFields, activeFilterField: nextActiveFilterField,
activeFilterField: nextActiveFilterField, filterScopeMap,
filterScopeMap, layout,
layout, });
});
this.setState({ setActiveFilterField(nextActiveFilterField);
activeFilterField: nextActiveFilterField, setFilterScopeMap({
filterScopeMap: {
...filterScopeMap, ...filterScopeMap,
...filterScopeTreeEntry, ...filterScopeTreeEntry,
} as FilterScopeMap, });
}); }
} },
} [
activeFilterField,
allFilterFields,
checkedFilterFields,
filterScopeMap,
layout,
],
);
onSearchInputChange(e: ChangeEvent<HTMLInputElement>): void { const onSearchInputChange = useCallback(
this.setState({ searchText: e.target.value }, this.filterTree); (e: ChangeEvent<HTMLInputElement>): void => {
} const newSearchText = e.target.value;
setSearchText(newSearchText);
filterTree(newSearchText);
},
[filterTree],
);
onClose(): void { const onClose = useCallback((): void => {
this.props.onCloseModal(); onCloseModal();
} }, [onCloseModal]);
onSave(): void { const onSave = useCallback((): void => {
const state = this.state as FilterScopeSelectorStateWithSelector; const allFilterFieldScopes = allFilterFields.reduce<
const { filterScopeMap } = state;
const allFilterFieldScopes = this.allfilterFields.reduce<
Record<string, ReturnType<typeof getFilterScopeFromNodesTree>> Record<string, ReturnType<typeof getFilterScopeFromNodesTree>>
>((map, filterKey) => { >((map, filterKey) => {
const { nodes } = filterScopeMap[filterKey]; const { nodes } = filterScopeMap[filterKey];
@@ -669,124 +737,32 @@ export default class FilterScopeSelector extends PureComponent<
}; };
}, {}); }, {});
this.props.updateDashboardFiltersScope(allFilterFieldScopes); updateDashboardFiltersScope(allFilterFieldScopes);
this.props.setUnsavedChanges(true); setUnsavedChanges(true);
// click Save button will do save and close modal // click Save button will do save and close modal
this.props.onCloseModal(); onCloseModal();
} }, [
allFilterFields,
filterScopeMap,
onCloseModal,
setUnsavedChanges,
updateDashboardFiltersScope,
]);
filterTree(): void { const renderFilterFieldList = (): ReactElement | null => (
const state = this.state as FilterScopeSelectorStateWithSelector; <FilterFieldTree
// Reset nodes back to unfiltered state activeKey={activeFilterField}
if (!state.searchText) { nodes={filterFieldNodes}
this.setState(prevState => { checked={checkedFilterFields}
const prev = prevState as FilterScopeSelectorStateWithSelector; expanded={expandedFilterIds}
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev; onClick={onChangeFilterField}
const key = getKeyForFilterScopeTree({ onCheck={onCheckFilterField}
activeFilterField: activeFilterField ?? undefined, onExpand={onExpandFilterField}
checkedFilterFields, />
}); );
const updatedEntry = {
...filterScopeMap[key],
nodesFiltered: filterScopeMap[key].nodes,
};
return {
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
});
} else {
const updater = (
prevState: FilterScopeSelectorState,
): FilterScopeSelectorState => {
const prev = prevState as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const nodesFiltered = filterScopeMap[key].nodes.reduce<
FilterScopeTreeNode[]
>(this.filterNodes, []);
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
const updatedEntry = {
...filterScopeMap[key],
nodesFiltered,
expanded,
};
return {
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
};
this.setState(updater);
}
}
filterNodes(
filtered: FilterScopeTreeNode[] = [],
node: FilterScopeTreeNode = { value: '', label: '' },
): FilterScopeTreeNode[] {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { searchText } = state;
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
this.filterNodes,
[],
);
if (
// Node's label matches the search string
node.label
.toLocaleLowerCase()
.indexOf((searchText ?? '').toLocaleLowerCase()) > -1 ||
// Or a children has a matching node
children.length
) {
filtered.push({ ...node, children });
}
return filtered;
}
renderFilterFieldList(): ReactElement | null {
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
activeFilterField,
filterFieldNodes,
checkedFilterFields,
expandedFilterIds,
} = state;
return (
<FilterFieldTree
activeKey={activeFilterField}
nodes={filterFieldNodes}
checked={checkedFilterFields}
expanded={expandedFilterIds}
onClick={this.onChangeFilterField}
onCheck={this.onCheckFilterField}
onExpand={this.onExpandFilterField}
/>
);
}
renderFilterScopeTree(): ReactElement {
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
filterScopeMap,
activeFilterField,
checkedFilterFields,
searchText,
} = state;
const renderFilterScopeTree = (): ReactElement => {
const key = getKeyForFilterScopeTree({ const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined, activeFilterField: activeFilterField ?? undefined,
checkedFilterFields, checkedFilterFields,
@@ -803,26 +779,23 @@ export default class FilterScopeSelector extends PureComponent<
placeholder={t('Search...')} placeholder={t('Search...')}
type="text" type="text"
value={searchText} value={searchText}
onChange={this.onSearchInputChange} onChange={onSearchInputChange}
/> />
<FilterScopeTree <FilterScopeTree
nodes={filterScopeMap[key].nodesFiltered} nodes={filterScopeMap[key].nodesFiltered}
checked={filterScopeMap[key].checked} checked={filterScopeMap[key].checked}
expanded={filterScopeMap[key].expanded} expanded={filterScopeMap[key].expanded}
onCheck={this.onCheckFilterScope} onCheck={onCheckFilterScope}
onExpand={this.onExpandFilterScope} onExpand={onExpandFilterScope}
// pass selectedFilterId prop to FilterScopeTree component, // pass selectedFilterId prop to FilterScopeTree component,
// to hide checkbox for selected filter field itself // to hide checkbox for selected filter field itself
selectedChartId={selectedChartId} selectedChartId={selectedChartId}
/> />
</> </>
); );
} };
renderEditingFiltersName(): ReactElement { const renderEditingFiltersName = (): ReactElement => {
const { dashboardFilters } = this.props;
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields } = state;
const currentFilterLabels = ([] as string[]) const currentFilterLabels = ([] as string[])
.concat(activeFilterField || checkedFilterFields) .concat(activeFilterField || checkedFilterFields)
.filter(Boolean) .filter(Boolean)
@@ -842,50 +815,42 @@ export default class FilterScopeSelector extends PureComponent<
</span> </span>
</div> </div>
); );
} };
render(): ReactElement { return (
const { showSelector } = this.state; <ScopeContainer>
<ScopeHeader>
<h4>{t('Configure filter scopes')}</h4>
{showSelector && renderEditingFiltersName()}
</ScopeHeader>
return ( <ScopeBody className="filter-scope-body">
<ScopeContainer> {!showSelector ? (
<ScopeHeader> <div className="warning-message">
<h4>{t('Configure filter scopes')}</h4> {t('There are no filters in this dashboard.')}
{showSelector && this.renderEditingFiltersName()} </div>
</ScopeHeader> ) : (
<ScopeSelector className="filters-scope-selector">
<ScopeBody className="filter-scope-body"> <div className={cx('filter-field-pane multi-edit-mode')}>
{!showSelector ? ( {renderFilterFieldList()}
<div className="warning-message">
{t('There are no filters in this dashboard.')}
</div> </div>
) : ( <div className="filter-scope-pane multi-edit-mode">
<ScopeSelector className="filters-scope-selector"> {renderFilterScopeTree()}
<div className={cx('filter-field-pane multi-edit-mode')}> </div>
{this.renderFilterFieldList()} </ScopeSelector>
</div> )}
<div className="filter-scope-pane multi-edit-mode"> </ScopeBody>
{this.renderFilterScopeTree()}
</div>
</ScopeSelector>
)}
</ScopeBody>
<ActionsContainer> <ActionsContainer>
<Button buttonSize="small" onClick={this.onClose}> <Button buttonSize="small" onClick={onClose}>
{t('Close')} {t('Close')}
</Button>
{showSelector && (
<Button buttonSize="small" buttonStyle="primary" onClick={onSave}>
{t('Save')}
</Button> </Button>
{showSelector && ( )}
<Button </ActionsContainer>
buttonSize="small" </ScopeContainer>
buttonStyle="primary" );
onClick={this.onSave}
>
{t('Save')}
</Button>
)}
</ActionsContainer>
</ScopeContainer>
);
}
} }

View File

@@ -128,6 +128,10 @@ const SliceContainer = styled.div`
const EMPTY_OBJECT: Record<string, never> = {}; const EMPTY_OBJECT: Record<string, never> = {};
// Stable no-op fallback for optional callbacks so we don't allocate a new
// function on every render (keeps referential equality for memoized children).
const NOOP = () => {};
// Helper function to get chart state with fallback // Helper function to get chart state with fallback
const getChartStateWithFallback = ( const getChartStateWithFallback = (
chartState: { state?: JsonObject } | undefined, chartState: { state?: JsonObject } | undefined,
@@ -763,11 +767,11 @@ const Chart = (props: ChartProps) => {
}, },
slice.viz_type, slice.viz_type,
)} )}
queriesResponse={chart.queriesResponse ?? undefined} queriesResponse={chart.queriesResponse ?? null}
timeout={timeout} timeout={timeout}
triggerQuery={chart.triggerQuery} triggerQuery={chart.triggerQuery}
vizType={slice.viz_type} vizType={slice.viz_type}
setControlValue={props.setControlValue} setControlValue={props.setControlValue ?? NOOP}
datasetsStatus={ datasetsStatus={
datasetsStatus as 'loading' | 'error' | 'complete' | undefined datasetsStatus as 'loading' | 'error' | 'complete' | undefined
} }

View File

@@ -17,9 +17,8 @@
* under the License. * under the License.
*/ */
import { PureComponent } from 'react'; import { useCallback, memo } from 'react';
import { css, styled } from '@apache-superset/core/theme'; import { css, styled } from '@apache-superset/core/theme';
import { Draggable } from '../../dnd/DragDroppable'; import { Draggable } from '../../dnd/DragDroppable';
import HoverMenu from '../../menu/HoverMenu'; import HoverMenu from '../../menu/HoverMenu';
import DeleteComponentButton from '../../DeleteComponentButton'; import DeleteComponentButton from '../../DeleteComponentButton';
@@ -63,50 +62,43 @@ const DividerLine = styled.div`
`} `}
`; `;
class Divider extends PureComponent<DividerProps> { function Divider({
constructor(props: DividerProps) { id,
super(props); parentId,
this.handleDeleteComponent = this.handleDeleteComponent.bind(this); component,
} depth,
parentComponent,
handleDeleteComponent() { index,
const { deleteComponent, id, parentId } = this.props; editMode,
handleComponentDrop,
deleteComponent,
}: DividerProps) {
const handleDeleteComponent = useCallback(() => {
deleteComponent(id, parentId); deleteComponent(id, parentId);
} }, [deleteComponent, id, parentId]);
render() { return (
const { <Draggable
component, component={component}
depth, parentComponent={parentComponent}
parentComponent, orientation="row"
index, index={index}
handleComponentDrop, depth={depth}
editMode, onDrop={handleComponentDrop}
} = this.props; editMode={editMode}
>
return ( {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<Draggable <div ref={dragSourceRef}>
component={component} {editMode && (
parentComponent={parentComponent} <HoverMenu position="left">
orientation="row" <DeleteComponentButton onDelete={handleDeleteComponent} />
index={index} </HoverMenu>
depth={depth} )}
onDrop={handleComponentDrop} <DividerLine className="dashboard-component dashboard-component-divider" />
editMode={editMode} </div>
> )}
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => ( </Draggable>
<div ref={dragSourceRef}> );
{editMode && (
<HoverMenu position="left">
<DeleteComponentButton onDelete={this.handleDeleteComponent} />
</HoverMenu>
)}
<DividerLine className="dashboard-component dashboard-component-divider" />
</div>
)}
</Draggable>
);
}
} }
export default Divider; export default memo(Divider);

View File

@@ -16,10 +16,9 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { PureComponent } from 'react'; import { useState, useCallback, memo } from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { css, styled } from '@apache-superset/core/theme'; import { css, styled } from '@apache-superset/core/theme';
import PopoverDropdown from '@superset-ui/core/components/PopoverDropdown'; import PopoverDropdown from '@superset-ui/core/components/PopoverDropdown';
import { EditableTitle } from '@superset-ui/core/components'; import { EditableTitle } from '@superset-ui/core/components';
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable'; import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
@@ -85,10 +84,6 @@ interface HeaderProps {
updateComponents: (changes: Record<string, ComponentShape>) => void; updateComponents: (changes: Record<string, ComponentShape>) => void;
} }
interface HeaderState {
isFocused: boolean;
}
const HeaderStyles = styled.div` const HeaderStyles = styled.div`
${({ theme }) => css` ${({ theme }) => css`
font-weight: ${theme.fontWeightStrong}; font-weight: ${theme.fontWeightStrong};
@@ -159,149 +154,141 @@ const HeaderStyles = styled.div`
`} `}
`; `;
class Header extends PureComponent<HeaderProps, HeaderState> { function Header({
handleChangeSize: (nextValue: string) => void; id,
handleChangeBackground: (nextValue: string) => void; dashboardId,
handleChangeText: (nextValue: string) => void; parentId,
component,
depth,
parentComponent,
index,
editMode,
embeddedMode,
handleComponentDrop,
deleteComponent,
updateComponents,
}: HeaderProps) {
const [isFocused, setIsFocused] = useState(false);
constructor(props: HeaderProps) { const handleChangeFocus = useCallback((nextFocus: boolean): void => {
super(props); setIsFocused(nextFocus);
this.state = { }, []);
isFocused: false,
};
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
this.handleChangeFocus = this.handleChangeFocus.bind(this);
this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
this.handleChangeSize = (nextValue: string) => const handleUpdateMeta = useCallback(
this.handleUpdateMeta('headerSize', nextValue); (metaKey: keyof ComponentMeta, nextValue: string): void => {
this.handleChangeBackground = (nextValue: string) => if (nextValue && component.meta[metaKey] !== nextValue) {
this.handleUpdateMeta('background', nextValue); updateComponents({
this.handleChangeText = (nextValue: string) => [component.id]: {
this.handleUpdateMeta('text', nextValue); ...component,
} meta: {
...component.meta,
handleChangeFocus(nextFocus: boolean): void { [metaKey]: nextValue,
this.setState(() => ({ isFocused: nextFocus })); },
}
handleUpdateMeta(metaKey: keyof ComponentMeta, nextValue: string): void {
const { updateComponents, component } = this.props;
if (nextValue && component.meta[metaKey] !== nextValue) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
[metaKey]: nextValue,
}, },
}, } as Record<string, ComponentShape>);
} as Record<string, ComponentShape>); }
} },
} [component, updateComponents],
);
handleDeleteComponent(): void { const handleChangeSize = useCallback(
const { deleteComponent, id, parentId } = this.props; (nextValue: string) => handleUpdateMeta('headerSize', nextValue),
[handleUpdateMeta],
);
const handleChangeBackground = useCallback(
(nextValue: string) => handleUpdateMeta('background', nextValue),
[handleUpdateMeta],
);
const handleChangeText = useCallback(
(nextValue: string) => handleUpdateMeta('text', nextValue),
[handleUpdateMeta],
);
const handleDeleteComponent = useCallback((): void => {
deleteComponent(id, parentId); deleteComponent(id, parentId);
} }, [deleteComponent, id, parentId]);
render() { const headerStyle = headerStyleOptions.find(
const { isFocused } = this.state; opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
);
const { const rowStyle = backgroundStyleOptions.find(
dashboardId, opt => opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
component, );
depth,
parentComponent,
index,
handleComponentDrop,
editMode,
embeddedMode,
} = this.props;
const headerStyle = headerStyleOptions.find( return (
opt => opt.value === (component.meta.headerSize || SMALL_HEADER), <Draggable
); component={component}
parentComponent={parentComponent}
const rowStyle = backgroundStyleOptions.find( orientation="row"
opt => index={index}
opt.value === (component.meta.background || BACKGROUND_TRANSPARENT), depth={depth}
); onDrop={handleComponentDrop}
disableDragDrop={isFocused}
return ( editMode={editMode}
<Draggable >
component={component} {({
parentComponent={parentComponent} dragSourceRef,
orientation="row" }: {
index={index} dragSourceRef: React.Ref<HTMLDivElement> | undefined;
depth={depth} }) => (
onDrop={handleComponentDrop} <div ref={dragSourceRef}>
disableDragDrop={isFocused} {editMode &&
editMode={editMode} depth <= 2 && ( // drag handle looks bad when nested
> <HoverMenu position="left">
{({ <DragHandle position="left" />
dragSourceRef, </HoverMenu>
}: { )}
dragSourceRef: React.Ref<HTMLDivElement> | undefined; <WithPopoverMenu
}) => ( onChangeFocus={handleChangeFocus}
<div ref={dragSourceRef}> menuItems={[
{editMode && <PopoverDropdown
depth <= 2 && ( // drag handle looks bad when nested id={`${component.id}-header-style`}
<HoverMenu position="left"> options={headerStyleOptions}
<DragHandle position="left" /> value={component.meta.headerSize as string}
onChange={handleChangeSize}
/>,
<BackgroundStyleDropdown
id={`${component.id}-background`}
value={component.meta.background as string}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
<HeaderStyles
className={cx(
'dashboard-component',
'dashboard-component-header',
headerStyle?.className,
rowStyle?.className,
)}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu> </HoverMenu>
)} )}
<WithPopoverMenu <EditableTitle
onChangeFocus={this.handleChangeFocus} title={component.meta.text}
menuItems={[ canEdit={editMode}
<PopoverDropdown onSaveTitle={handleChangeText}
id={`${component.id}-header-style`} showTooltip={false}
options={headerStyleOptions} />
value={component.meta.headerSize as string} {!editMode && !embeddedMode && (
onChange={this.handleChangeSize} <AnchorLink
/>, id={component.id}
<BackgroundStyleDropdown dashboardId={Number(dashboardId)}
id={`${component.id}-background`}
value={component.meta.background as string}
onChange={this.handleChangeBackground}
/>,
]}
editMode={editMode}
>
<HeaderStyles
className={cx(
'dashboard-component',
'dashboard-component-header',
headerStyle?.className,
rowStyle?.className,
)}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
<EditableTitle
title={component.meta.text}
canEdit={editMode}
onSaveTitle={this.handleChangeText}
showTooltip={false}
/> />
{!editMode && !embeddedMode && ( )}
<AnchorLink </HeaderStyles>
id={component.id} </WithPopoverMenu>
dashboardId={Number(dashboardId)} </div>
/> )}
)} </Draggable>
</HeaderStyles> );
</WithPopoverMenu>
</div>
)}
</Draggable>
);
}
} }
export default Header; export default memo(Header);

View File

@@ -16,14 +16,16 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { PureComponent } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ErrorInfo } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import cx from 'classnames'; import cx from 'classnames';
import type { JsonObject } from '@superset-ui/core'; import type { JsonObject } from '@superset-ui/core';
import type { ResizeStartCallback, ResizeCallback } from 're-resizable'; import type { ResizeStartCallback, ResizeCallback } from 're-resizable';
import { ErrorBoundary } from 'src/components';
import { css, styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation'; import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import { SafeMarkdown } from '@superset-ui/core/components'; import { SafeMarkdown } from '@superset-ui/core/components';
import { EditorHost } from 'src/core/editors'; import { EditorHost } from 'src/core/editors';
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils'; import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
@@ -82,16 +84,6 @@ export interface MarkdownStateProps {
export type MarkdownProps = MarkdownOwnProps & MarkdownStateProps; export type MarkdownProps = MarkdownOwnProps & MarkdownStateProps;
export interface MarkdownState {
isFocused: boolean;
markdownSource: string;
editor: EditorInstance | null;
editorMode: 'preview' | 'edit';
undoLength: number;
redoLength: number;
hasError?: boolean;
}
// TODO: localize // TODO: localize
const MARKDOWN_PLACE_HOLDER = `# ✨Header 1 const MARKDOWN_PLACE_HOLDER = `# ✨Header 1
## ✨Header 2 ## ✨Header 2
@@ -140,193 +132,199 @@ interface DragChildProps {
dragSourceRef: React.RefCallback<HTMLElement>; dragSourceRef: React.RefCallback<HTMLElement>;
} }
class Markdown extends PureComponent<MarkdownProps, MarkdownState> { function Markdown({
renderStartTime: number; id,
parentId,
component,
parentComponent,
index,
depth,
editMode,
availableColumnCount,
columnWidth,
onResizeStart,
onResize,
onResizeStop,
deleteComponent,
handleComponentDrop,
updateComponents,
logEvent,
addDangerToast,
undoLength,
redoLength,
htmlSanitization,
htmlSchemaOverrides,
}: MarkdownProps) {
const [isFocused, setIsFocused] = useState(false);
const [markdownSource, setMarkdownSource] = useState<string>(
component.meta.code as string,
);
const [editor, setEditorState] = useState<EditorInstance | null>(null);
const [editorMode, setEditorMode] = useState<'preview' | 'edit'>('preview');
const [hasError, setHasError] = useState(false);
constructor(props: MarkdownProps) { const renderStartTimeRef = useRef(Logger.getTimestamp());
super(props); const prevUndoLengthRef = useRef(undoLength);
this.state = { const prevRedoLengthRef = useRef(redoLength);
isFocused: false, const prevComponentWidthRef = useRef(component.meta.width);
markdownSource: props.component.meta.code as string, const prevColumnWidthRef = useRef(columnWidth);
editor: null,
editorMode: 'preview',
undoLength: props.undoLength,
redoLength: props.redoLength,
};
this.renderStartTime = Logger.getTimestamp();
this.handleChangeFocus = this.handleChangeFocus.bind(this); // getDerivedStateFromProps equivalent for undo/redo. Run during render
this.handleChangeEditorMode = this.handleChangeEditorMode.bind(this); // (not in an effect) so the new markdownSource is applied before the commit,
this.handleMarkdownChange = this.handleMarkdownChange.bind(this); // avoiding a one-frame flash of the old content. React bails out of the
this.handleDeleteComponent = this.handleDeleteComponent.bind(this); // intermediate render without committing it.
this.handleResizeStart = this.handleResizeStart.bind(this); const isUndoRedo =
this.setEditor = this.setEditor.bind(this); undoLength !== prevUndoLengthRef.current ||
this.shouldFocusMarkdown = this.shouldFocusMarkdown.bind(this); redoLength !== prevRedoLengthRef.current;
if (isUndoRedo) {
setMarkdownSource(component.meta.code as string);
setHasError(false);
prevUndoLengthRef.current = undoLength;
prevRedoLengthRef.current = redoLength;
} }
componentDidMount(): void { // Sync external code changes (not from undo/redo) while in preview mode.
this.props.logEvent(LOG_ACTIONS_RENDER_CHART, { useEffect(() => {
viz_type: 'markdown',
start_offset: this.renderStartTime,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - this.renderStartTime,
});
}
static getDerivedStateFromProps(
nextProps: MarkdownProps,
state: MarkdownState,
): MarkdownState | null {
const { hasError, editorMode, markdownSource, undoLength, redoLength } =
state;
const {
component: nextComponent,
undoLength: nextUndoLength,
redoLength: nextRedoLength,
} = nextProps;
// user click undo or redo ?
if (nextUndoLength !== undoLength || nextRedoLength !== redoLength) {
return {
...state,
undoLength: nextUndoLength,
redoLength: nextRedoLength,
markdownSource: nextComponent.meta.code as string,
hasError: false,
};
}
if ( if (
!isUndoRedo &&
!hasError && !hasError &&
editorMode === 'preview' && editorMode === 'preview' &&
nextComponent.meta.code !== markdownSource component.meta.code !== markdownSource
) { ) {
return { setMarkdownSource(component.meta.code as string);
...state,
markdownSource: nextComponent.meta.code as string,
};
} }
}, [isUndoRedo, component.meta.code, hasError, editorMode, markdownSource]);
return state; // componentDidMount equivalent: log render event
} useEffect(() => {
logEvent(LOG_ACTIONS_RENDER_CHART, {
viz_type: 'markdown',
start_offset: renderStartTimeRef.current,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - renderStartTimeRef.current,
});
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
static getDerivedStateFromError(): { hasError: boolean } { // componentDidUpdate equivalent: resize editor when width changes
return { useEffect(() => {
hasError: true,
};
}
componentDidUpdate(prevProps: MarkdownProps): void {
if ( if (
this.state.editor && editor &&
(prevProps.component.meta.width !== this.props.component.meta.width || (prevComponentWidthRef.current !== component.meta.width ||
prevProps.columnWidth !== this.props.columnWidth) prevColumnWidthRef.current !== columnWidth)
) { ) {
// Handle both Ace editor (resize method) and EditorHandle (no resize needed) // Handle both Ace editor (resize method) and EditorHandle (no resize needed)
if (typeof this.state.editor.resize === 'function') { if (typeof editor.resize === 'function') {
this.state.editor.resize(true); editor.resize(true);
} }
} }
} prevComponentWidthRef.current = component.meta.width;
prevColumnWidthRef.current = columnWidth;
}, [editor, component.meta.width, columnWidth]);
componentDidCatch(): void { const updateMarkdownContent = useCallback((): void => {
if (this.state.editor && this.state.editorMode === 'preview') { if (component.meta.code !== markdownSource) {
this.props.addDangerToast(
t(
'This markdown component has an error. Please revert your recent changes.',
),
);
}
}
setEditor(editor: EditorInstance): void {
// EditorHandle or Ace editor instance
// For Ace: editor.getSession().setUseWrapMode(true)
// For EditorHandle: wrapEnabled is handled via options
if (editor?.getSession) {
editor.getSession!().setUseWrapMode(true);
}
this.setState({
editor,
});
}
handleChangeFocus(nextFocus: boolean | number): void {
const nextFocused = !!nextFocus;
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
this.setState(() => ({ isFocused: nextFocused }));
this.handleChangeEditorMode(nextEditMode);
}
handleChangeEditorMode(mode: 'edit' | 'preview'): void {
const nextState: MarkdownState = {
...this.state,
editorMode: mode,
};
if (mode === 'preview') {
this.updateMarkdownContent();
nextState.hasError = false;
}
this.setState(nextState);
}
updateMarkdownContent(): void {
const { updateComponents, component } = this.props;
if (component.meta.code !== this.state.markdownSource) {
updateComponents({ updateComponents({
[component.id]: { [component.id]: {
...component, ...component,
meta: { meta: {
...component.meta, ...component.meta,
code: this.state.markdownSource, code: markdownSource,
}, },
}, },
}); });
} }
} }, [component, markdownSource, updateComponents]);
handleMarkdownChange(nextValue: string): void { const setEditor = useCallback((editorInstance: EditorInstance): void => {
this.setState({ // EditorHandle or Ace editor instance
markdownSource: nextValue, // For Ace: editor.getSession().setUseWrapMode(true)
}); // For EditorHandle: wrapEnabled is handled via options
} if (editorInstance?.getSession) {
editorInstance.getSession!().setUseWrapMode(true);
handleDeleteComponent(): void {
const { deleteComponent, id, parentId } = this.props;
deleteComponent(id, parentId);
}
handleResizeStart(...args: Parameters<ResizeStartCallback>): void {
const { editorMode } = this.state;
const { editMode, onResizeStart } = this.props;
const isEditing = editorMode === 'edit';
onResizeStart(...args);
if (editMode && isEditing) {
this.updateMarkdownContent();
} }
} setEditorState(editorInstance);
}, []);
shouldFocusMarkdown( const handleChangeEditorMode = useCallback(
event: MouseEvent, (mode: 'edit' | 'preview'): void => {
container: HTMLElement | null, if (mode === 'preview') {
menuRef: HTMLElement | null, updateMarkdownContent();
): boolean { setHasError(false);
if (container?.contains(event.target as Node)) return true; }
if (menuRef?.contains(event.target as Node)) return true; setEditorMode(mode);
},
[updateMarkdownContent],
);
return false; const handleChangeFocus = useCallback(
} (nextFocus: boolean | number): void => {
const nextFocused = !!nextFocus;
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
setIsFocused(nextFocused);
handleChangeEditorMode(nextEditMode);
},
[handleChangeEditorMode],
);
renderEditMode(): JSX.Element { const handleMarkdownChange = useCallback((nextValue: string): void => {
return ( setMarkdownSource(nextValue);
}, []);
const handleDeleteComponent = useCallback((): void => {
deleteComponent(id, parentId);
}, [deleteComponent, id, parentId]);
const handleResizeStart = useCallback(
(...args: Parameters<ResizeStartCallback>): void => {
const isEditing = editorMode === 'edit';
onResizeStart(...args);
if (editMode && isEditing) {
updateMarkdownContent();
}
},
[editorMode, editMode, onResizeStart, updateMarkdownContent],
);
const shouldFocusMarkdown = useCallback(
(
event: MouseEvent,
container: HTMLElement | null,
menuRef: HTMLElement | null,
): boolean => {
if (container?.contains(event.target as Node)) return true;
if (menuRef?.contains(event.target as Node)) return true;
return false;
},
[],
);
const handleRenderError = useCallback(
(_error: Error, _info: ErrorInfo): void => {
setHasError(true);
if (editorMode === 'preview') {
addDangerToast(
t(
'This markdown component has an error. Please revert your recent changes.',
),
);
}
},
[addDangerToast, editorMode],
);
const renderEditMode = useMemo(
() => (
<EditorHost <EditorHost
id={`markdown-editor-${this.props.id}`} id={`markdown-editor-${id}`}
onChange={this.handleMarkdownChange} onChange={handleMarkdownChange}
width="100%" width="100%"
height="100%" height="100%"
value={ value={
// this allows "select all => delete" to give an empty editor // this allows "select all => delete" to give an empty editor
typeof this.state.markdownSource === 'string' typeof markdownSource === 'string'
? this.state.markdownSource ? markdownSource
: MARKDOWN_PLACE_HOLDER : MARKDOWN_PLACE_HOLDER
} }
language="markdown" language="markdown"
@@ -336,126 +334,116 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
onReady={(handle: EditorInstance) => { onReady={(handle: EditorInstance) => {
// The handle provides access to the underlying editor for resize // The handle provides access to the underlying editor for resize
if (handle && typeof handle.focus === 'function') { if (handle && typeof handle.focus === 'function') {
this.setEditor(handle); setEditor(handle);
} }
}} }}
data-test="editor" data-test="editor"
/> />
); ),
} [id, markdownSource, handleMarkdownChange, setEditor],
);
renderPreviewMode(): JSX.Element { const renderPreviewMode = useMemo(
const { hasError } = this.state; () => (
return (
<SafeMarkdown <SafeMarkdown
source={ source={
hasError hasError
? MARKDOWN_ERROR_MESSAGE ? MARKDOWN_ERROR_MESSAGE
: this.state.markdownSource || MARKDOWN_PLACE_HOLDER : markdownSource || MARKDOWN_PLACE_HOLDER
} }
htmlSanitization={this.props.htmlSanitization} htmlSanitization={htmlSanitization}
htmlSchemaOverrides={this.props.htmlSchemaOverrides} htmlSchemaOverrides={htmlSchemaOverrides}
/> />
); ),
} [hasError, markdownSource, htmlSanitization, htmlSchemaOverrides],
);
render() { // inherit the size of parent columns
const { isFocused, editorMode } = this.state; const widthMultiple =
parentComponent.type === COLUMN_TYPE
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
: component.meta.width || GRID_MIN_COLUMN_COUNT;
const { const isEditing = editorMode === 'edit';
component,
parentComponent,
index,
depth,
availableColumnCount,
columnWidth,
onResize,
onResizeStop,
handleComponentDrop,
editMode,
} = this.props;
// inherit the size of parent columns const menuItems = useMemo(
const widthMultiple = () => [
parentComponent.type === COLUMN_TYPE <MarkdownModeDropdown
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT key={`${component.id}-mode`}
: component.meta.width || GRID_MIN_COLUMN_COUNT; id={`${component.id}-mode`}
value={editorMode}
onChange={handleChangeEditorMode}
/>,
],
[component.id, editorMode, handleChangeEditorMode],
);
const isEditing = editorMode === 'edit'; return (
<Draggable
return ( component={component}
<Draggable parentComponent={parentComponent}
component={component} orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
parentComponent={parentComponent} index={index}
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'} depth={depth}
index={index} onDrop={handleComponentDrop}
depth={depth} disableDragDrop={isFocused}
onDrop={handleComponentDrop} editMode={editMode}
disableDragDrop={isFocused} >
editMode={editMode} {({ dragSourceRef }: DragChildProps) => (
> <WithPopoverMenu
{({ dragSourceRef }: DragChildProps) => ( onChangeFocus={handleChangeFocus}
<WithPopoverMenu shouldFocus={shouldFocusMarkdown}
onChangeFocus={this.handleChangeFocus} menuItems={menuItems}
shouldFocus={this.shouldFocusMarkdown} editMode={editMode}
menuItems={[ >
<MarkdownModeDropdown <MarkdownStyles
key={`${component.id}-mode`} data-test="dashboard-markdown-editor"
id={`${component.id}-mode`} className={cx(
value={this.state.editorMode} 'dashboard-markdown',
onChange={this.handleChangeEditorMode} isEditing && 'dashboard-markdown--editing',
/>, )}
]} id={component.id}
editMode={editMode}
> >
<MarkdownStyles <ResizableContainer
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
id={component.id} id={component.id}
adjustableWidth={parentComponent.type === ROW_TYPE}
adjustableHeight
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
> >
<ResizableContainer <div
id={component.id} ref={dragSourceRef}
adjustableWidth={parentComponent.type === ROW_TYPE} className="dashboard-component dashboard-component-chart-holder"
adjustableHeight data-test="dashboard-component-chart-holder"
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={this.handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
> >
<div {editMode && (
ref={dragSourceRef} <HoverMenu position="top">
className="dashboard-component dashboard-component-chart-holder" <DeleteComponentButton onDelete={handleDeleteComponent} />
data-test="dashboard-component-chart-holder" </HoverMenu>
)}
<ErrorBoundary
key={hasError ? 'markdown-error' : 'markdown-ok'}
onError={handleRenderError}
showMessage={false}
> >
{editMode && ( {editMode && isEditing ? renderEditMode : renderPreviewMode}
<HoverMenu position="top"> </ErrorBoundary>
<DeleteComponentButton </div>
onDelete={this.handleDeleteComponent} </ResizableContainer>
/> </MarkdownStyles>
</HoverMenu> </WithPopoverMenu>
)} )}
{editMode && isEditing </Draggable>
? this.renderEditMode() );
: this.renderPreviewMode()}
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
)}
</Draggable>
);
}
} }
interface ReduxState { interface ReduxState {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { PureComponent } from 'react'; import { memo } from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { css, styled } from '@apache-superset/core/theme'; import { css, styled } from '@apache-superset/core/theme';
import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable'; import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable';
@@ -62,37 +62,37 @@ const NewComponentPlaceholder = styled.div`
`} `}
`; `;
export default class DraggableNewComponent extends PureComponent<DraggableNewComponentProps> { function DraggableNewComponent({
static defaultProps = { label,
className: null, id,
IconComponent: undefined, type,
}; className,
meta,
render() { IconComponent,
const { label, id, type, className, meta, IconComponent } = this.props; }: DraggableNewComponentProps) {
return (
return ( <DragDroppable
<DragDroppable component={{ type, id, meta }}
component={{ type, id, meta }} parentComponent={{
parentComponent={{ id: NEW_COMPONENTS_SOURCE_ID,
id: NEW_COMPONENTS_SOURCE_ID, type: NEW_COMPONENT_SOURCE_TYPE,
type: NEW_COMPONENT_SOURCE_TYPE, }}
}} index={0}
index={0} depth={0}
depth={0} editMode
editMode >
> {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => ( <NewComponent ref={dragSourceRef} data-test="new-component">
<NewComponent ref={dragSourceRef} data-test="new-component"> <NewComponentPlaceholder
<NewComponentPlaceholder className={cx('new-component-placeholder', className)}
className={cx('new-component-placeholder', className)} >
> {IconComponent && <IconComponent iconSize="xl" />}
{IconComponent && <IconComponent iconSize="xl" />} </NewComponentPlaceholder>
</NewComponentPlaceholder> {label}
{label} </NewComponent>
</NewComponent> )}
)} </DragDroppable>
</DragDroppable> );
);
}
} }
export default memo(DraggableNewComponent);

View File

@@ -16,11 +16,9 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { PureComponent } from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { t } from '@apache-superset/core/translation'; import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme'; import { css, styled } from '@apache-superset/core/theme';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions'; import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import PopoverDropdown, { import PopoverDropdown, {
OptionProps, OptionProps,
@@ -90,18 +88,19 @@ function renderOption(option: OptionProps) {
); );
} }
export default class BackgroundStyleDropdown extends PureComponent<BackgroundStyleDropdownProps> { export default function BackgroundStyleDropdown({
render() { id,
const { id, value, onChange } = this.props; value,
return ( onChange,
<PopoverDropdown }: BackgroundStyleDropdownProps) {
id={id} return (
options={backgroundStyleOptions} <PopoverDropdown
value={value} id={id}
onChange={onChange} options={backgroundStyleOptions}
renderButton={renderButton} value={value}
renderOption={renderOption} onChange={onChange}
/> renderButton={renderButton}
); renderOption={renderOption}
} />
);
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable react/no-unused-state */
/** /**
* Licensed to the Apache Software Foundation (ASF) under one * Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file * or more contributor license agreements. See the NOTICE file
@@ -17,15 +16,15 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { RefObject, ReactNode, PureComponent } from 'react'; import { RefObject, ReactNode, useCallback, memo } from 'react';
import { styled } from '@apache-superset/core/theme'; import { styled } from '@apache-superset/core/theme';
import cx from 'classnames'; import cx from 'classnames';
interface HoverMenuProps { interface HoverMenuProps {
position: 'left' | 'top'; position?: 'left' | 'top';
innerRef: RefObject<HTMLDivElement>; innerRef?: RefObject<HTMLDivElement> | null;
children: ReactNode; children?: ReactNode;
onHover?: (data: { isHovered: boolean }) => void; onHover?: (data: { isHovered: boolean }) => void;
} }
@@ -66,45 +65,41 @@ const HoverStyleOverrides = styled.div`
} }
`; `;
export default class HoverMenu extends PureComponent<HoverMenuProps> { function HoverMenu({
static defaultProps = { position = 'left',
position: 'left', innerRef = null,
innerRef: null, children = null,
children: null, onHover,
}; }: HoverMenuProps) {
const handleMouseEnter = useCallback(() => {
handleMouseEnter = () => {
const { onHover } = this.props;
if (onHover) { if (onHover) {
onHover({ isHovered: true }); onHover({ isHovered: true });
} }
}; }, [onHover]);
handleMouseLeave = () => { const handleMouseLeave = useCallback(() => {
const { onHover } = this.props;
if (onHover) { if (onHover) {
onHover({ isHovered: false }); onHover({ isHovered: false });
} }
}; }, [onHover]);
render() { return (
const { innerRef, position, children } = this.props; <HoverStyleOverrides className="hover-menu-container">
return ( <div
<HoverStyleOverrides className="hover-menu-container"> ref={innerRef}
<div className={cx(
ref={innerRef} 'hover-menu',
className={cx( position === 'left' && 'hover-menu--left',
'hover-menu', position === 'top' && 'hover-menu--top',
position === 'left' && 'hover-menu--left', )}
position === 'top' && 'hover-menu--top', onMouseEnter={handleMouseEnter}
)} onMouseLeave={handleMouseLeave}
onMouseEnter={this.handleMouseEnter} data-test="hover-menu"
onMouseLeave={this.handleMouseLeave} >
data-test="hover-menu" {children}
> </div>
{children} </HoverStyleOverrides>
</div> );
</HoverStyleOverrides>
);
}
} }
export default memo(HoverMenu);

View File

@@ -16,9 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { PureComponent } from 'react';
import { t } from '@apache-superset/core/translation'; import { t } from '@apache-superset/core/translation';
import PopoverDropdown, { import PopoverDropdown, {
OnChangeHandler, OnChangeHandler,
} from '@superset-ui/core/components/PopoverDropdown'; } from '@superset-ui/core/components/PopoverDropdown';
@@ -40,18 +38,18 @@ const dropdownOptions = [
}, },
]; ];
export default class MarkdownModeDropdown extends PureComponent<MarkdownModeDropdownProps> { export default function MarkdownModeDropdown({
render() { id,
const { id, value, onChange } = this.props; value,
onChange,
return ( }: MarkdownModeDropdownProps) {
<PopoverDropdown return (
data-test="markdown-mode-dropdown" <PopoverDropdown
id={id} data-test="markdown-mode-dropdown"
options={dropdownOptions} id={id}
value={value} options={dropdownOptions}
onChange={onChange} value={value}
/> onChange={onChange}
); />
} );
} }

View File

@@ -106,7 +106,9 @@ test('should unfocus when another component is clicked', async () => {
<WithPopoverMenu <WithPopoverMenu
{...props} {...props}
editMode editMode
shouldFocus={(event, container) => container?.contains(event.target)} shouldFocus={(event, container, _menuRef) =>
container?.contains(event.target) ?? false
}
onChangeFocus={onChangeFocusA} onChangeFocus={onChangeFocusA}
> >
<div id="child-a" /> <div id="child-a" />
@@ -117,7 +119,9 @@ test('should unfocus when another component is clicked', async () => {
<WithPopoverMenu <WithPopoverMenu
{...props} {...props}
editMode editMode
shouldFocus={(event, container) => container?.contains(event.target)} shouldFocus={(event, container, _menuRef) =>
container?.contains(event.target) ?? false
}
onChangeFocus={onChangeFocusB} onChangeFocus={onChangeFocusB}
> >
<div id="child-b" /> <div id="child-b" />

View File

@@ -16,7 +16,15 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { ReactNode, CSSProperties, PureComponent } from 'react'; import {
ReactNode,
CSSProperties,
useCallback,
useEffect,
useRef,
useState,
memo,
} from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { addAlpha } from '@superset-ui/core'; import { addAlpha } from '@superset-ui/core';
import { css, styled } from '@apache-superset/core/theme'; import { css, styled } from '@apache-superset/core/theme';
@@ -26,26 +34,32 @@ type ShouldFocusContainer = HTMLDivElement & {
}; };
interface WithPopoverMenuProps { interface WithPopoverMenuProps {
children: ReactNode; children?: ReactNode;
disableClick: boolean; disableClick?: boolean;
menuItems: ReactNode[]; menuItems?: ReactNode[];
onChangeFocus: (focus: boolean) => void; onChangeFocus?: ((focus: boolean) => void) | null;
isFocused: boolean; isFocused?: boolean;
// Event argument is left as "any" because of the clash. In defaultProps it seems // Event argument is left as "any" because of the clash. In props it seems
// like it should be React.FocusEvent<>, however from handleClick() we can also // like it should be React.FocusEvent<>, however from handleClick() we can also
// derive that type is EventListenerOrEventListenerObject. // derive that type is EventListenerOrEventListenerObject.
shouldFocus: ( shouldFocus?: (
event: any, event: any,
container: ShouldFocusContainer, container: ShouldFocusContainer | null,
menuRef: HTMLDivElement | null, menuRef: HTMLDivElement | null,
) => boolean; ) => boolean;
editMode: boolean; editMode?: boolean;
style: CSSProperties; style?: CSSProperties | null;
} }
interface WithPopoverMenuState { const defaultShouldFocus = (
isFocused: boolean; event: any,
} container: ShouldFocusContainer | null,
menuRef: HTMLDivElement | null,
): boolean => {
if (container?.contains(event.target)) return true;
if (menuRef?.contains(event.target)) return true;
return false;
};
const WithPopoverMenuStyles = styled.div` const WithPopoverMenuStyles = styled.div`
${({ theme }) => css` ${({ theme }) => css`
@@ -104,151 +118,114 @@ const PopoverMenuStyles = styled.div`
`} `}
`; `;
export default class WithPopoverMenu extends PureComponent< function WithPopoverMenu({
WithPopoverMenuProps, children = null,
WithPopoverMenuState disableClick = false,
> { menuItems = [],
container: ShouldFocusContainer; onChangeFocus = null,
isFocused: isFocusedProp = false,
shouldFocus: shouldFocusFunc = defaultShouldFocus,
editMode = false,
style = null,
}: WithPopoverMenuProps) {
const [isFocused, setIsFocused] = useState(isFocusedProp);
const containerRef = useRef<ShouldFocusContainer | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
// Tracks the native event that just triggered focus via the container's
// onClick so the document-level listener (registered once focused) can
// skip it. Without this, the same click bubbles to document after a
// re-render has detached its event.target, causing shouldFocus to return
// false and immediately undoing the focus.
const focusEventRef = useRef<Event | null>(null);
menuRef: HTMLDivElement | null; const handleClick = useCallback(
(event: any) => {
if (!editMode) {
return;
}
focusEvent: Event | null; const nativeEvent = event.nativeEvent || event;
if (focusEventRef.current === nativeEvent) {
focusEventRef.current = null;
return;
}
static defaultProps = { const shouldFocusResult = shouldFocusFunc(
children: null, event,
disableClick: false, containerRef.current,
onChangeFocus: null, menuRef.current,
menuItems: [], );
isFocused: false,
shouldFocus: ( if (shouldFocusResult === isFocused) return;
event: any,
container: ShouldFocusContainer, if (!disableClick && shouldFocusResult && !isFocused) {
menuRef: HTMLDivElement | null, focusEventRef.current = nativeEvent;
) => { setIsFocused(true);
if (container?.contains(event.target)) return true; if (onChangeFocus) onChangeFocus(true);
if (menuRef?.contains(event.target)) return true; } else if (!shouldFocusResult && isFocused) {
return false; setIsFocused(false);
if (onChangeFocus) onChangeFocus(false);
}
}, },
style: null, [editMode, shouldFocusFunc, isFocused, disableClick, onChangeFocus],
}; );
constructor(props: WithPopoverMenuProps) { // Keep the latest handleClick in a ref so the document listeners can be
super(props); // registered via a stable wrapper. This keeps the listener effect dependent
this.state = { // only on focus/editMode transitions, instead of thrashing (remove + re-add)
isFocused: props.isFocused!, // every time handleClick's identity changes.
}; const handleClickRef = useRef(handleClick);
this.menuRef = null; useEffect(() => {
this.focusEvent = null; handleClickRef.current = handleClick;
this.setRef = this.setRef.bind(this); }, [handleClick]);
this.setMenuRef = this.setMenuRef.bind(this);
this.handleClick = this.handleClick.bind(this);
}
componentDidUpdate(prevProps: WithPopoverMenuProps) { // Handle prop-driven focus changes and add/remove document listeners
if (this.props.editMode && this.props.isFocused && !this.state.isFocused) { useEffect(() => {
document.addEventListener('click', this.handleClick); if (editMode && isFocusedProp && !isFocused) {
document.addEventListener('drag', this.handleClick); setIsFocused(true);
this.setState({ isFocused: true }); } else if (isFocused && !editMode) {
} else if (this.state.isFocused && !this.props.editMode) { setIsFocused(false);
document.removeEventListener('click', this.handleClick);
document.removeEventListener('drag', this.handleClick);
this.setState({ isFocused: false });
} }
} }, [editMode, isFocusedProp, isFocused]);
componentWillUnmount() { // Add/remove document event listeners only on focus/editMode transitions.
document.removeEventListener('click', this.handleClick); useEffect(() => {
document.removeEventListener('drag', this.handleClick); if (isFocused && editMode) {
} const listener = (event: Event) => handleClickRef.current(event);
document.addEventListener('click', listener);
document.addEventListener('drag', listener);
setRef(ref: ShouldFocusContainer) { return () => {
this.container = ref; document.removeEventListener('click', listener);
} document.removeEventListener('drag', listener);
};
setMenuRef(ref: HTMLDivElement | null) {
this.menuRef = ref;
}
shouldHandleFocusChange(shouldFocus: boolean): boolean {
const { disableClick } = this.props;
const { isFocused } = this.state;
return (
(!disableClick && shouldFocus && !isFocused) ||
(!shouldFocus && isFocused)
);
}
handleClick(event: any) {
if (!this.props.editMode) {
return;
} }
return undefined;
}, [isFocused, editMode]);
// Skip if this is the same event that just triggered focus via onClick. return (
// The document-level listener registered during focus will see the same <WithPopoverMenuStyles
// event bubble up; by that time a re-render may have detached the ref={containerRef}
// original event.target, causing shouldFocus to return false and onClick={handleClick}
// immediately undoing the focus. role="none"
const nativeEvent = event.nativeEvent || event; className={cx(
if (this.focusEvent === nativeEvent) { 'with-popover-menu',
this.focusEvent = null; editMode && isFocused && 'with-popover-menu--focused',
return; )}
} style={style ?? undefined}
>
const { {children}
onChangeFocus, {editMode && isFocused && menuItems?.some(Boolean) && (
shouldFocus: shouldFocusFunc, <PopoverMenuStyles ref={menuRef}>
disableClick, {menuItems.map((node: ReactNode, i: number) => (
} = this.props; <div className="menu-item" key={`menu-item-${i}`}>
{node}
const shouldFocus = shouldFocusFunc(event, this.container, this.menuRef); </div>
))}
if (shouldFocus === this.state.isFocused) return; </PopoverMenuStyles>
)}
if (!disableClick && shouldFocus && !this.state.isFocused) { </WithPopoverMenuStyles>
document.addEventListener('click', this.handleClick); );
document.addEventListener('drag', this.handleClick);
this.focusEvent = event.nativeEvent || event;
this.setState(() => ({ isFocused: true }));
if (onChangeFocus) onChangeFocus(true);
} else if (!shouldFocus && this.state.isFocused) {
document.removeEventListener('click', this.handleClick);
document.removeEventListener('drag', this.handleClick);
this.setState(() => ({ isFocused: false }));
if (onChangeFocus) onChangeFocus(false);
}
}
render() {
const { children, menuItems, editMode, style } = this.props;
const { isFocused } = this.state;
return (
<WithPopoverMenuStyles
ref={this.setRef}
onClick={this.handleClick}
role="none"
className={cx(
'with-popover-menu',
editMode && isFocused && 'with-popover-menu--focused',
)}
style={style}
>
{children}
{editMode && isFocused && (menuItems?.length ?? 0) > 0 && (
<PopoverMenuStyles ref={this.setMenuRef}>
{menuItems.map((node: ReactNode, i: number) => (
<div className="menu-item" key={`menu-item-${i}`}>
{node}
</div>
))}
</PopoverMenuStyles>
)}
</WithPopoverMenuStyles>
);
}
} }
export default memo(WithPopoverMenu);

View File

@@ -1,148 +0,0 @@
/**
* 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, useState, useCallback } from 'react';
import type { FormInstance } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { BaseModalBody, BaseForm, BaseModalWrapper } from './SharedStyles';
import { ModalFooter } from './ModalFooter';
export interface BaseConfigModalProps {
isOpen: boolean;
title: string;
expanded?: boolean;
onCancel: () => void;
onSave: () => void;
leftPane: ReactNode;
rightPane: ReactNode;
footer?: ReactNode;
form?: FormInstance;
onValuesChange?: (changedValues: any, allValues: any) => void;
canSave?: boolean;
saveAlertVisible?: boolean;
onDismissSaveAlert?: () => void;
onConfirmCancel?: () => void;
onToggleExpand?: () => void;
testId?: string;
maskClosable?: boolean;
destroyOnClose?: boolean;
centered?: boolean;
}
export const BaseConfigModal = ({
isOpen,
title,
expanded = false,
onCancel,
onSave,
leftPane,
rightPane,
footer,
form,
onValuesChange,
canSave = true,
saveAlertVisible = false,
onDismissSaveAlert,
onConfirmCancel,
onToggleExpand,
testId = 'base-config-modal',
maskClosable = false,
destroyOnClose = true,
centered = true,
}: BaseConfigModalProps) => {
const [internalExpanded, setInternalExpanded] = useState(false);
const isExpandedControlled = onToggleExpand !== undefined;
const isExpanded = isExpandedControlled ? expanded : internalExpanded;
const handleToggleExpand = useCallback(() => {
if (isExpandedControlled && onToggleExpand) {
onToggleExpand();
} else {
setInternalExpanded(!internalExpanded);
}
}, [isExpandedControlled, onToggleExpand, internalExpanded]);
const handleCancel = useCallback(() => {
onCancel();
}, [onCancel]);
const handleSave = useCallback(() => {
onSave();
}, [onSave]);
const handleDismissSaveAlert = useCallback(() => {
if (onDismissSaveAlert) {
onDismissSaveAlert();
}
}, [onDismissSaveAlert]);
const handleConfirmCancel = useCallback(() => {
if (onConfirmCancel) {
onConfirmCancel();
} else {
onCancel();
}
}, [onConfirmCancel, onCancel]);
const defaultFooter = (
<ModalFooter
onCancel={handleCancel}
onSave={handleSave}
onConfirmCancel={handleConfirmCancel}
onDismiss={handleDismissSaveAlert}
saveAlertVisible={saveAlertVisible}
canSave={canSave}
expanded={isExpanded}
onToggleExpand={handleToggleExpand}
saveButtonTestId={`${testId}-save-button`}
cancelButtonTestId={`${testId}-cancel-button`}
/>
);
return (
<BaseModalWrapper
open={isOpen}
onCancel={handleCancel}
onOk={handleSave}
title={title}
footer={footer || defaultFooter}
centered={centered}
destroyOnClose={destroyOnClose}
maskClosable={maskClosable}
data-test={testId}
expanded={isExpanded}
>
<ErrorBoundary>
<BaseModalBody expanded={isExpanded}>
<BaseForm
form={form}
onValuesChange={onValuesChange}
layout="vertical"
css={{ width: '100%' }}
>
{leftPane}
{rightPane}
</BaseForm>
</BaseModalBody>
</ErrorBoundary>
</BaseModalWrapper>
);
};
export default BaseConfigModal;

View File

@@ -0,0 +1,228 @@
/**
* 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, waitFor } from 'spec/helpers/testing-library';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { NativeFilterType } from '@superset-ui/core';
import type { Filter } from '@superset-ui/core';
import FilterValue from './FilterValue';
const mockGetChartDataRequest = jest.fn();
jest.mock('src/components/Chart/chartAction', () => ({
getChartDataRequest: (...args: unknown[]) => mockGetChartDataRequest(...args),
}));
jest.mock('src/middleware/asyncEvent', () => ({
waitForAsyncData: jest.fn(),
}));
jest.mock('@superset-ui/core', () => {
const original = jest.requireActual('@superset-ui/core');
return {
...original,
getChartMetadataRegistry: () => ({
get: () => ({ enableNoResults: false }),
}),
SuperChart: (props: Record<string, unknown>) => (
<div data-test="mock-super-chart" data-chart-type={props.chartType}>
SuperChart
</div>
),
isFeatureEnabled: () => false,
getClientErrorObject: (_err: unknown) =>
Promise.resolve({
message: 'Something went wrong',
errors: [
{ message: 'Test error', error_type: 'GENERIC_BACKEND_ERROR' },
],
}),
};
});
jest.mock('../useFilterOutlined', () => ({
useFilterOutlined: () => ({
outlinedFilterId: undefined,
lastUpdated: 0,
}),
}));
const mockUseFilterDependencies = jest.fn().mockReturnValue({});
const mockUseTransitiveParentIds = jest.fn().mockReturnValue([]);
jest.mock('./state', () => ({
useFilterDependencies: (...args: unknown[]) =>
mockUseFilterDependencies(...args),
useTransitiveParentIds: (...args: unknown[]) =>
mockUseTransitiveParentIds(...args),
}));
const mockStore = configureStore([thunk]);
const createMockFilter = (overrides: Partial<Filter> = {}): Filter => ({
id: 'NATIVE_FILTER-1',
name: 'Test Filter',
filterType: 'filter_select',
targets: [{ datasetId: 1, column: { name: 'country' } }],
defaultDataMask: {},
controlValues: {},
cascadeParentIds: [],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
type: NativeFilterType.NativeFilter,
description: 'Test filter description',
...overrides,
});
const getDefaultStoreState = () => ({
dashboardInfo: { id: 1 },
dashboardState: {
isRefreshing: false,
isFiltersRefreshing: false,
directPathToChild: [],
directPathLastUpdated: 0,
},
nativeFilters: {
filters: {
'NATIVE_FILTER-1': createMockFilter(),
},
filterSets: {},
},
dataMask: {},
charts: {},
dashboardLayout: { present: {} },
});
const defaultProps = {
filter: createMockFilter(),
dataMaskSelected: {},
onFilterSelectionChange: jest.fn(),
inView: true,
};
function renderFilterValue(
propOverrides: Record<string, unknown> = {},
stateOverrides: Record<string, unknown> = {},
) {
const state = { ...getDefaultStoreState(), ...stateOverrides };
const store = mockStore(state);
const mergedProps = { ...defaultProps, ...propOverrides };
return render(
<Provider store={store}>
<FilterValue {...(mergedProps as typeof defaultProps)} />
</Provider>,
);
}
beforeEach(() => {
jest.clearAllMocks();
});
test('renders loading spinner when filter has a data source', () => {
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
renderFilterValue();
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.queryByTestId('mock-super-chart')).not.toBeInTheDocument();
});
test('renders SuperChart after data loads successfully', async () => {
mockGetChartDataRequest.mockResolvedValue({
response: { status: 200 },
json: { result: [{ data: [{ country: 'US' }] }] },
});
renderFilterValue();
await waitFor(() => {
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
});
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
test('renders error state when API call fails', async () => {
mockGetChartDataRequest.mockRejectedValue(
new Response(JSON.stringify({ message: 'Server Error' }), { status: 500 }),
);
renderFilterValue();
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
// No ErrorMessageComponent is registered for GENERIC_BACKEND_ERROR in the
// test environment, so FilterValue renders its fallback ErrorAlert.
expect(await screen.findByText('Network error')).toBeInTheDocument();
});
test('does not fetch data when filter has not been in view', () => {
renderFilterValue({ inView: false });
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
});
test('does not render loading spinner when filter has no data source', () => {
const filterWithoutDataSource = createMockFilter({
targets: [{ column: { name: 'country' } }],
});
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
renderFilterValue({ filter: filterWithoutDataSource });
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
});
test('skips data fetch when cascade parent filters have no values selected', () => {
// useFilterDependencies returns dependencies with a filter (from parent defaults),
// but dataMaskSelected has no extraFormData for the parent -- counts disagree, so
// the component skips the fetch.
mockUseFilterDependencies.mockReturnValue({
filters: [{ col: 'region', op: 'IN', val: ['US'] }],
});
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
const childFilter = createMockFilter({
id: 'NATIVE_FILTER-CHILD',
cascadeParentIds: ['NATIVE_FILTER-PARENT'],
});
const stateWithParent = {
nativeFilters: {
filters: {
'NATIVE_FILTER-CHILD': childFilter,
'NATIVE_FILTER-PARENT': createMockFilter({
id: 'NATIVE_FILTER-PARENT',
}),
},
filterSets: {},
},
};
renderFilterValue(
{
filter: childFilter,
dataMaskSelected: {},
},
stateWithParent,
);
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
});

View File

@@ -41,7 +41,8 @@ import {
getClientErrorObject, getClientErrorObject,
isChartCustomization, isChartCustomization,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme'; import { styled, SupersetTheme } from '@apache-superset/core/theme';
import { useTheme } from '@emotion/react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { isEqual, isEqualWith } from 'lodash'; import { isEqual, isEqualWith } from 'lodash';
import { getChartDataRequest } from 'src/components/Chart/chartAction'; import { getChartDataRequest } from 'src/components/Chart/chartAction';
@@ -141,6 +142,7 @@ const FilterValue: FC<FilterValueProps> = ({
clearAllTrigger, clearAllTrigger,
onClearAllComplete, onClearAllComplete,
}) => { }) => {
const theme = useTheme() as SupersetTheme;
const { id, targets, filterType } = filter; const { id, targets, filterType } = filter;
const isCustomization = isChartCustomization(filter); const isCustomization = isChartCustomization(filter);
const allowedTimeGrains = isCustomization const allowedTimeGrains = isCustomization
@@ -487,6 +489,7 @@ const FilterValue: FC<FilterValueProps> = ({
enableNoResults={metadata?.enableNoResults} enableNoResults={metadata?.enableNoResults}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}
hooks={hooks} hooks={hooks}
theme={theme}
/> />
)} )}
</StyledDiv> </StyledDiv>

View File

@@ -1,29 +0,0 @@
/**
* 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 { useSelector } from 'react-redux';
import { useMemo } from 'react';
import { RootState } from 'src/dashboard/types';
import getChartIdsFromLayout from '../getChartIdsFromLayout';
export const useAllChartIds = () => {
const layout = useSelector(
(state: RootState) => state.dashboardLayout.present,
);
return useMemo(() => getChartIdsFromLayout(layout), [layout]);
};

View File

@@ -1,113 +0,0 @@
/**
* 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 { Layout, LayoutItem } from 'src/dashboard/types';
import { TAB_TYPE, DASHBOARD_GRID_TYPE } from '../componentTypes';
import { DASHBOARD_ROOT_ID } from '../constants';
import findNonTabChildChartIds from './findNonTabChildChartIds';
interface TopLevelNode {
id: string;
type: string;
parent_type: string | null;
parent_id: string | null;
index: number | null;
depth: number;
slice_ids: number[];
}
interface RecurseParams {
node: LayoutItem | undefined;
index?: number | null;
depth: number;
parentType?: string | null;
parentId?: string | null;
}
// This function traverses the layout to identify top grid + tab level components
// for which we track load times
function findTopLevelComponentIds(layout: Layout): TopLevelNode[] {
const topLevelNodes: TopLevelNode[] = [];
function recurseFromNode({
node,
index = null,
depth,
parentType = null,
parentId = null,
}: RecurseParams): void {
if (!node) return;
let nextParentType = parentType;
let nextParentId = parentId;
let nextDepth = depth;
if (node.type === TAB_TYPE || node.type === DASHBOARD_GRID_TYPE) {
const chartIds = findNonTabChildChartIds({
layout,
id: node.id,
});
topLevelNodes.push({
id: node.id,
type: node.type,
parent_type: parentType,
parent_id: parentId,
index,
depth,
slice_ids: chartIds,
});
nextParentId = node.id;
nextParentType = node.type;
nextDepth += 1;
}
if (node.children && node.children.length) {
node.children.forEach((childId, childIndex) => {
recurseFromNode({
node: layout[childId],
index: childIndex,
parentType: nextParentType,
parentId: nextParentId,
depth: nextDepth,
});
});
}
}
recurseFromNode({
node: layout[DASHBOARD_ROOT_ID],
depth: 0,
});
return topLevelNodes;
}
// This method is called frequently, so cache results
let cachedLayout: Layout | undefined;
let cachedTopLevelNodes: TopLevelNode[] = [];
export default function findTopLevelComponentIdsWithCache(
layout: Layout,
): TopLevelNode[] {
if (layout === cachedLayout) {
return cachedTopLevelNodes;
}
cachedLayout = layout;
cachedTopLevelNodes = findTopLevelComponentIds(layout);
return cachedTopLevelNodes;
}

View File

@@ -1,59 +0,0 @@
/**
* 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 { ChartState } from 'src/explore/types';
import { Layout } from 'src/dashboard/types';
import findTopLevelComponentIds from './findTopLevelComponentIds';
import childChartsDidLoad from './childChartsDidLoad';
interface GetLoadStatsParams {
layout: Layout;
chartQueries: Record<string, Partial<ChartState>>;
}
interface LoadStats {
didLoad: boolean;
id: string;
minQueryStartTime: number | null;
[key: string]: unknown;
}
export default function getLoadStatsPerTopLevelComponent({
layout,
chartQueries,
}: GetLoadStatsParams): Record<string, LoadStats> {
const topLevelComponents = findTopLevelComponentIds(layout);
const stats: Record<string, LoadStats> = {};
topLevelComponents.forEach(topLevelComponent => {
const { id, ...restStats } = topLevelComponent;
const { didLoad, minQueryStartTime } = childChartsDidLoad({
id,
layout,
chartQueries,
});
stats[id] = {
didLoad,
id,
minQueryStartTime,
...restStats,
};
});
return stats;
}

View File

@@ -1,42 +0,0 @@
/**
* 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.
*/
export const stopPeriodicRender = (refreshTimer?: number) => {
if (refreshTimer) {
clearInterval(refreshTimer);
}
};
interface SetPeriodicRunnerProps {
interval?: number;
periodicRender: TimerHandler;
refreshTimer?: number;
}
export default function setPeriodicRunner({
interval = 0,
periodicRender,
refreshTimer,
}: SetPeriodicRunnerProps) {
stopPeriodicRender(refreshTimer);
if (interval > 0) {
return setInterval(periodicRender, interval);
}
return 0;
}

View File

@@ -437,6 +437,32 @@ describe('UploadDataModal - Form Validation', () => {
expect(screen.getByText('Table name is required')).toBeInTheDocument(); expect(screen.getByText('Table name is required')).toBeInTheDocument();
}); });
}); });
test('database validation error clears after selecting a database', async () => {
render(<UploadDataModal {...csvProps} />, { useRedux: true });
const uploadButton = screen.getByRole('button', { name: 'Upload' });
await userEvent.click(uploadButton);
await waitFor(() => {
expect(
screen.getByText('Selecting a database is required'),
).toBeInTheDocument();
});
const selectDatabase = screen.getByRole('combobox', {
name: /select a database/i,
});
await userEvent.click(selectDatabase);
await waitFor(() => screen.getByText('database1'));
await userEvent.click(screen.getByText('database1'));
await waitFor(() => {
expect(
screen.queryByText('Selecting a database is required'),
).not.toBeInTheDocument();
});
});
}); });
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks

View File

@@ -578,8 +578,11 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
return Promise.resolve(); return Promise.resolve();
}; };
const validateDatabase = (_: any, value: string) => { const validateDatabase = (
if (!currentDatabaseId) { _: any,
value: { value: number; label: string } | null | undefined,
) => {
if (!value?.value) {
return Promise.reject(t('Selecting a database is required')); return Promise.reject(t('Selecting a database is required'));
} }
return Promise.resolve(); return Promise.resolve();

View File

@@ -1,126 +0,0 @@
/**
* 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 { Link } from 'react-router-dom';
import { t } from '@apache-superset/core/translation';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import { CardStyles } from 'src/views/CRUD/utils';
import {
Button,
Dropdown,
ConfirmStatusChange,
ListViewCard,
} from '@superset-ui/core/components';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { Icons } from '@superset-ui/core/components/Icons';
import { Tag } from 'src/views/CRUD/types';
import { deleteTags } from 'src/features/tags/tags';
import { assetUrl } from 'src/utils/assetUrl';
interface TagCardProps {
tag: Tag;
hasPerm: (name: string) => boolean;
bulkSelectEnabled: boolean;
refreshData: () => void;
loading: boolean;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
tagFilter?: string;
userId?: string | number;
showThumbnails?: boolean;
}
function TagCard({
tag,
hasPerm,
refreshData,
addDangerToast,
addSuccessToast,
showThumbnails,
}: TagCardProps) {
const canDelete = hasPerm('can_write');
const handleTagDelete = (tag: Tag) => {
deleteTags([tag], addSuccessToast, addDangerToast);
refreshData();
};
const menuItems: MenuItem[] = [];
if (canDelete) {
menuItems.push({
key: 'delete-tag',
label: (
<ConfirmStatusChange
title={t('Please confirm')}
description={
<>
{t('Are you sure you want to delete')} <b>{tag.name}</b>?
</>
}
onConfirm={() => handleTagDelete(tag)}
>
{confirmDelete => (
<div
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
data-test="dashboard-card-option-delete-button"
>
<Icons.DeleteOutlined iconSize="l" /> {t('Delete')}
</div>
)}
</ConfirmStatusChange>
),
});
}
return (
<CardStyles>
<ListViewCard
title={tag.name}
cover={
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
<></>
) : null
}
url={undefined}
linkComponent={Link}
imgFallbackURL={assetUrl(
'/static/assets/images/dashboard-card-fallback.svg',
)}
description={t('Modified %s', tag.changed_on_delta_humanized)}
actions={
<ListViewCard.Actions
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
<Dropdown menu={{ items: menuItems }} trigger={['click', 'hover']}>
<Button buttonSize="xsmall" buttonStyle="link">
<Icons.MoreOutlined iconSize="xl" />
</Button>
</Dropdown>
</ListViewCard.Actions>
}
/>
</CardStyles>
);
}
export default TagCard;

View File

@@ -1,39 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useRef } from 'react';
import { isDefined } from '@superset-ui/core';
export const useMemoCompare = <T>(
next: T,
compare: (prev: T | undefined, next: T) => boolean,
) => {
const previousRef = useRef<T>();
const previous = previousRef.current;
const isEqual = compare(previous, next);
useEffect(() => {
if (!isEqual) {
previousRef.current = next;
}
});
if (!isDefined(previous)) {
return next;
}
return isEqual ? previous : next;
};

View File

@@ -1,32 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useRef } from 'react';
export function useOpenerRef(active: boolean) {
const openerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (active) {
openerRef.current = document.activeElement as HTMLElement;
}
}, [active]);
return openerRef;
}

View File

@@ -1,30 +0,0 @@
/**
* 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.
*/
export enum DashboardVirtualizationMode {
None = 'NONE',
Viewport = 'VIEWPORT',
Paginated = 'PAGINATED',
}
export const isDashboardVirtualizationEnabled = (
virtualizationMode: DashboardVirtualizationMode,
) =>
virtualizationMode === DashboardVirtualizationMode.Viewport ||
virtualizationMode === DashboardVirtualizationMode.Paginated;

View File

@@ -1157,7 +1157,6 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
self._scheduled_dttm = scheduled_dttm self._scheduled_dttm = scheduled_dttm
self._execution_id = UUID(task_id) self._execution_id = UUID(task_id)
@transaction()
def run(self) -> None: def run(self) -> None:
try: try:
self.validate() self.validate()
@@ -1170,6 +1169,21 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
) )
user = security_manager.find_user(username) user = security_manager.find_user(username)
with override_user(user):
# Pre-commit any permalink rows before the state machine's
# @transaction() opens. When called inside a transaction,
# CreateDashboardPermalinkCommand only flushes (not commits),
# leaving the row invisible to Playwright's separate DB
# connection. Running get_dashboard_urls() here — outside any
# transaction — lets the command commit normally. The state
# machine's inner call to get_dashboard_urls() hits get_entry()
# for the same deterministic UUID and returns the
# already-committed row without a second INSERT.
if self._model.dashboard_id:
BaseReportState(
self._model, self._scheduled_dttm, self._execution_id
).get_dashboard_urls()
start_time = datetime.utcnow() start_time = datetime.utcnow()
with override_user(user): with override_user(user):
ReportScheduleStateMachine( ReportScheduleStateMachine(