Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,5 @@ docker/*local*
test-report.html
superset/static/stats/statistics.html
.aider*

.cursor
5 changes: 4 additions & 1 deletion superset-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

100 changes: 100 additions & 0 deletions superset-frontend/src/components/UnsavedChangesModal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* 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 { t, styled } from '@superset-ui/core';
import { ReactNode } from 'react';
import Modal from 'src/components/Modal';
import Button from 'src/components/Button';

const DescriptionContainer = styled.div`
line-height: ${({ theme }) => theme.gridUnit * 4}px;
padding-top: ${({ theme }) => theme.gridUnit * 2}px;
`;

interface UnsavedChangesModalProps {
description?: ReactNode;
onSave: () => void;
onDiscard: () => void;
onCancel: () => void;
show: boolean;
title?: ReactNode;
primaryButtonLoading?: boolean;
}

export default function UnsavedChangesModal({
description,
onSave,
onDiscard,
onCancel,
show,
title,
primaryButtonLoading = false,
}: UnsavedChangesModalProps) {
const defaultTitle = t('Unsaved changes');
const defaultDescription = t(
'You have unsaved changes. What would you like to do?',
);

const customFooter = (
<>
<Button
key="discard"
onClick={onDiscard}
buttonStyle="default"
data-test="unsaved-changes-modal-discard-button"
>
{t('Discard')}
</Button>
<Button
key="cancel"
onClick={onCancel}
buttonStyle="default"
data-test="unsaved-changes-modal-cancel-button"
>
{t('Cancel')}
</Button>
<Button
key="save"
onClick={onSave}
buttonStyle="primary"
loading={primaryButtonLoading}
data-test="unsaved-changes-modal-save-button"
>
{t('Save')}
</Button>
</>
);

return (
<Modal
onHide={onCancel}
show={show}
title={title || defaultTitle}
footer={customFooter}
hideFooter={false}
centered
maskClosable={false}
>
<DescriptionContainer>
{description || defaultDescription}
</DescriptionContainer>
</Modal>
);
}


173 changes: 170 additions & 3 deletions superset-frontend/src/dashboard/containers/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { createContext, lazy, FC, useEffect, useMemo, useRef } from 'react';
import { createContext, lazy, FC, useEffect, useMemo, useRef, useState } from 'react';
import { Global } from '@emotion/react';
import { useHistory } from 'react-router-dom';
import { t, useTheme } from '@superset-ui/core';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import Loading from 'src/components/Loading';
import UnsavedChangesModal from 'src/components/UnsavedChangesModal';
import {
useDashboard,
useDashboardCharts,
Expand All @@ -40,12 +41,17 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
import {
setDatasetsStatus,
saveDashboardRequest,
setUnsavedChanges,
} from 'src/dashboard/actions/dashboardState';
import {
getFilterValue,
getPermalinkValue,
} from 'src/dashboard/components/nativeFilters/FilterBar/keyValue';
import DashboardContainer from 'src/dashboard/containers/Dashboard';
import { SAVE_TYPE_OVERWRITE, DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';

import { nanoid } from 'nanoid';
import { RootState } from '../types';
Expand Down Expand Up @@ -214,7 +220,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
// when dashboard unmounts or changes
return injectCustomCss(css);
}
return () => {};
return () => { };
}, [css]);

useEffect(() => {
Expand All @@ -230,6 +236,160 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
const relevantDataMask = useSelector(selectRelevantDatamask);
const activeFilters = useSelector(selectActiveFilters);

// Navigation blocking for unsaved changes
const hasUnsavedChanges = useSelector<RootState, boolean>(
state => !!state.dashboardState.hasUnsavedChanges,
);
const editMode = useSelector<RootState, boolean>(
state => !!state.dashboardState.editMode,
);
const dashboardInfo = useSelector<RootState, any>(
state => state.dashboardInfo,
);
const layout = useSelector<RootState, any>(
state => state.dashboardLayout.present,
);
const dashboardState = useSelector<RootState, any>(
state => state.dashboardState,
);

const [showUnsavedModal, setShowUnsavedModal] = useState(false);
const [pendingNavigation, setPendingNavigation] = useState<string | null>(
null,
);
const [isSaving, setIsSaving] = useState(false);
const unblockRef = useRef<(() => void) | null>(null);

// Set up navigation blocking
useEffect(() => {
if (editMode && hasUnsavedChanges) {
// Block navigation when in edit mode with unsaved changes
// history.block() returns an unblock function
unblockRef.current = history.block((location, action) => {
// Only block if navigating to a different route
if (location.pathname !== history.location.pathname) {
setPendingNavigation(location.pathname);
setShowUnsavedModal(true);
// Return false to prevent navigation (this prevents browser prompt)
// We'll handle navigation manually via our modal
return '';
}
// Allow navigation within the same route (e.g., hash changes)
return true;
});
} else {
// Unblock navigation when not in edit mode or no unsaved changes
if (unblockRef.current) {
unblockRef.current();
unblockRef.current = null;
}
}

return () => {
if (unblockRef.current) {
unblockRef.current();
unblockRef.current = null;
}
};
}, [editMode, hasUnsavedChanges, history]);

// Handle Save action
const handleSave = useMemo(
() => () => {
if (!dashboardInfo?.id) {
return;
}

setIsSaving(true);
const currentColorNamespace =
dashboardInfo?.metadata?.color_namespace ||
dashboardState.colorNamespace;
const currentColorScheme =
dashboardInfo?.metadata?.color_scheme || dashboardState.colorScheme;
const dashboardTitle =
layout[DASHBOARD_HEADER_ID]?.meta?.text || dashboardInfo.dashboard_title;

const data = {
certified_by: dashboardInfo.certified_by,
certification_details: dashboardInfo.certification_details,
css: dashboardState.css || '',
dashboard_title: dashboardTitle,
last_modified_time: dashboardInfo.last_modified_time,
owners: dashboardInfo.owners,
roles: dashboardInfo.roles,
slug: dashboardInfo.slug,
metadata: {
...dashboardInfo?.metadata,
color_namespace: currentColorNamespace,
color_scheme: currentColorScheme,
positions: layout,
refresh_frequency: dashboardState.shouldPersistRefreshFrequency
? dashboardState.refreshFrequency
: dashboardInfo.metadata?.refresh_frequency,
},
};

// Temporarily unblock to allow navigation after save
const currentUnblock = unblockRef.current;
if (currentUnblock) {
currentUnblock();
unblockRef.current = null;
}

dispatch(
saveDashboardRequest(data, dashboardInfo.id, SAVE_TYPE_OVERWRITE),
)
.then(() => {
setIsSaving(false);
setShowUnsavedModal(false);
// Proceed with navigation after save
const navPath = pendingNavigation;
setPendingNavigation(null);
if (navPath) {
history.push(navPath);
}
})
.catch(() => {
// Save failed or requires confirmation - keep modal open
setIsSaving(false);
// Don't navigate if save failed
});
},
[dashboardInfo, layout, dashboardState, dispatch, history, pendingNavigation],
);

// Handle Discard action
const handleDiscard = useMemo(
() => () => {
// Temporarily unblock to allow navigation
const currentUnblock = unblockRef.current;
if (currentUnblock) {
currentUnblock();
unblockRef.current = null;
}

// Clear unsaved changes flag
dispatch(setUnsavedChanges(false));
setShowUnsavedModal(false);
// Proceed with navigation
const navPath = pendingNavigation;
setPendingNavigation(null);
if (navPath) {
history.push(navPath);
}
},
[dispatch, history, pendingNavigation],
);

// Handle Cancel action
const handleCancel = useMemo(
() => () => {
setShowUnsavedModal(false);
setPendingNavigation(null);
},
[],
);

if (error) throw error; // caught in error boundary

const globalStyles = useMemo(
Expand Down Expand Up @@ -260,6 +420,13 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
{DashboardBuilderComponent}
</DashboardContainer>
</DashboardPageIdContext.Provider>
<UnsavedChangesModal
show={showUnsavedModal}
onSave={handleSave}
onDiscard={handleDiscard}
onCancel={handleCancel}
primaryButtonLoading={isSaving}
/>
</>
) : (
<Loading />
Expand Down
Loading