This is an automated email from the ASF dual-hosted git repository.

diegopucci pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 057218d87f feat: Add confirmation modal for unsaved changes (#33809)
057218d87f is described below

commit 057218d87ff3f2c8d2eb4a89d31a2b50e8a5c606
Author: Gabriel Torres Ruiz <gabo2...@gmail.com>
AuthorDate: Tue Jul 1 13:38:51 2025 -0300

    feat: Add confirmation modal for unsaved changes (#33809)
---
 .../src/components/Modal/Modal.tsx                 |   9 +-
 .../UnsavedChangesModal.stories.tsx}               |  57 ++--
 .../UnsavedChangesModal.test.tsx                   |  96 +++++++
 .../src/components/UnsavedChangesModal/index.tsx   | 129 +++++++++
 .../superset-ui-core/src/components/index.ts       |   1 +
 .../AlteredSliceTag/AlteredSliceTag.test.jsx       | 279 --------------------
 .../AlteredSliceTag/AlteredSliceTag.test.tsx       |  97 +++++++
 .../AlteredSliceTag/AlteredSliceTagMocks.ts        |  18 +-
 .../src/components/AlteredSliceTag/index.tsx       | 128 +--------
 .../src/components/AlteredSliceTag/types.ts        |  26 +-
 .../src/components/AlteredSliceTag/utils/index.ts  | 107 ++++++++
 .../components/AlteredSliceTag/utils/utils.test.ts | 291 +++++++++++++++++++++
 .../dashboard/components/Header/Header.test.tsx    | 103 +++++++-
 .../src/dashboard/components/Header/index.jsx      |  27 +-
 .../FiltersConfigModal/FiltersConfigModal.test.tsx |  13 +-
 .../ExploreChartHeader/ExploreChartHeader.test.tsx | 190 +++++++++++++-
 .../components/ExploreChartHeader/index.jsx        |  89 +++++--
 .../components/ExploreViewContainer/index.jsx      |   1 +
 .../src/explore/exploreUtils/formData.test.ts      |  88 ++++++-
 .../src/explore/exploreUtils/formData.ts           |  31 +--
 .../src/hooks/useUnsavedChangesPrompt/index.ts     | 124 +++++++++
 .../useUnsavedChangesPrompt.test.tsx               | 106 ++++++++
 superset-frontend/src/pages/Chart/Chart.test.tsx   |  16 +-
 .../AlteredSliceTag/types.ts => types/DiffType.ts} |  20 --
 .../getChartFormDiffs/getChartFormDiffs.test.ts    | 115 ++++++++
 .../src/utils/getChartFormDiffs/index.ts           |  66 +++++
 .../sanitizeFormData/index.ts}                     |  15 +-
 .../sanitizeFormData/sanitizeFormData.test.ts}     |   2 +-
 28 files changed, 1689 insertions(+), 555 deletions(-)

diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/Modal/Modal.tsx 
b/superset-frontend/packages/superset-ui-core/src/components/Modal/Modal.tsx
index 9be6a15dd1..4bd716f1f1 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/Modal/Modal.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/components/Modal/Modal.tsx
@@ -228,13 +228,16 @@ const CustomModal = ({
   };
 
   let FooterComponent;
-  if (isValidElement(footer)) {
+
+  // This safely avoids injecting "closeModal" into native elements like <div> 
or <span>
+  if (isValidElement(footer) && typeof footer.type === 'function')
     // If a footer component is provided inject a closeModal function
     // so the footer can provide a "close" button if desired
     FooterComponent = cloneElement(footer, {
       closeModal: handleOnHide,
     } as Partial<unknown>);
-  }
+  else FooterComponent = footer;
+
   const modalFooter = isNil(FooterComponent)
     ? [
         <Button
@@ -309,7 +312,7 @@ const CustomModal = ({
       open={show}
       title={<ModalTitle />}
       closeIcon={
-        <span className="close" aria-hidden="true">
+        <span data-test="close-modal-btn" className="close" aria-hidden="true">
           ×
         </span>
       }
diff --git a/superset-frontend/src/components/AlteredSliceTag/types.ts 
b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.stories.tsx
similarity index 52%
copy from superset-frontend/src/components/AlteredSliceTag/types.ts
copy to 
superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.stories.tsx
index e1f6108608..4742c4da8f 100644
--- a/superset-frontend/src/components/AlteredSliceTag/types.ts
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.stories.tsx
@@ -16,45 +16,32 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import type { QueryFormData } from '@superset-ui/core';
+import { ReactElement } from 'react';
+import { UnsavedChangesModal, type UnsavedChangesModalProps } from '.';
 
-export interface AlteredSliceTagProps {
-  origFormData: QueryFormData;
-  currentFormData: QueryFormData;
-}
-
-export interface ControlMap {
-  [key: string]: {
-    label?: string;
-    type?: string;
-  };
-}
-
-export type FilterItemType = {
-  comparator?: string | string[];
-  subject: string;
-  operator: string;
-  label?: string;
+export default {
+  title: 'Components/UnsavedChangesModal',
+  component: UnsavedChangesModal,
 };
 
-export type DiffItemType<
-  T = FilterItemType | number | string | Record<string | number, any>,
-> =
-  | T[]
-  | boolean
-  | number
-  | string
-  | Record<string | number, any>
-  | null
-  | undefined;
+export const InteractiveUnsavedChangesModal = (
+  props: UnsavedChangesModalProps,
+): ReactElement => (
+  <UnsavedChangesModal {...props}>
+    If you don't save, changes will be lost.
+  </UnsavedChangesModal>
+);
 
-export type DiffType = {
-  before: DiffItemType;
-  after: DiffItemType;
+InteractiveUnsavedChangesModal.args = {
+  showModal: true,
+  onHide: () => {},
+  handleSave: () => {},
+  onConfirmNavigation: () => {},
+  title: 'Unsaved Changes',
 };
 
-export type RowType = {
-  before: string | number;
-  after: string | number;
-  control: string;
+InteractiveUnsavedChangesModal.argTypes = {
+  onHide: { action: 'onHide' },
+  handleSave: { action: 'handleSave' },
+  onConfirmNavigation: { action: 'onConfirmNavigation' },
 };
diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.test.tsx
 
b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.test.tsx
new file mode 100644
index 0000000000..995bd704d6
--- /dev/null
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.test.tsx
@@ -0,0 +1,96 @@
+/**
+ * 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, userEvent } from '@superset-ui/core/spec';
+import { UnsavedChangesModal } from '.';
+
+test('should render nothing if showModal is false', () => {
+  const { queryByRole } = render(
+    <UnsavedChangesModal
+      showModal={false}
+      onHide={() => {}}
+      handleSave={() => {}}
+      onConfirmNavigation={() => {}}
+    />,
+  );
+
+  expect(queryByRole('dialog')).not.toBeInTheDocument();
+});
+
+test('should render the UnsavedChangesModal component if showModal is true', 
async () => {
+  const { queryByRole } = render(
+    <UnsavedChangesModal
+      showModal
+      onHide={() => {}}
+      handleSave={() => {}}
+      onConfirmNavigation={() => {}}
+    />,
+  );
+
+  expect(queryByRole('dialog')).toBeInTheDocument();
+});
+
+test('should only call onConfirmNavigation when clicking the Discard button', 
async () => {
+  const mockOnHide = jest.fn();
+  const mockHandleSave = jest.fn();
+  const mockOnConfirmNavigation = jest.fn();
+
+  render(
+    <UnsavedChangesModal
+      showModal
+      onHide={mockOnHide}
+      handleSave={mockHandleSave}
+      onConfirmNavigation={mockOnConfirmNavigation}
+    />,
+  );
+
+  const discardButton: HTMLElement = await screen.findByRole('button', {
+    name: /discard/i,
+  });
+
+  userEvent.click(discardButton);
+
+  expect(mockOnConfirmNavigation).toHaveBeenCalled();
+  expect(mockHandleSave).not.toHaveBeenCalled();
+  expect(mockOnHide).not.toHaveBeenCalled();
+});
+
+test('should only call handleSave when clicking the Save button', async () => {
+  const mockOnHide = jest.fn();
+  const mockHandleSave = jest.fn();
+  const mockOnConfirmNavigation = jest.fn();
+
+  render(
+    <UnsavedChangesModal
+      showModal
+      onHide={mockOnHide}
+      handleSave={mockHandleSave}
+      onConfirmNavigation={mockOnConfirmNavigation}
+    />,
+  );
+
+  const saveButton: HTMLElement = await screen.findByRole('button', {
+    name: /save/i,
+  });
+
+  userEvent.click(saveButton);
+
+  expect(mockHandleSave).toHaveBeenCalled();
+  expect(mockOnHide).not.toHaveBeenCalled();
+  expect(mockOnConfirmNavigation).not.toHaveBeenCalled();
+});
diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/index.tsx
 
b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/index.tsx
new file mode 100644
index 0000000000..fcab6f3263
--- /dev/null
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/index.tsx
@@ -0,0 +1,129 @@
+/**
+ * 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, css } from '@superset-ui/core';
+import { Icons, Modal, Typography } from '@superset-ui/core/components';
+import { Button } from '@superset-ui/core/components/Button';
+import type { FC, ReactElement } from 'react';
+
+const StyledModalTitle = styled(Typography.Title)`
+  && {
+    font-weight: 600;
+    margin: 0;
+  }
+`;
+
+const StyledModalBody = styled(Typography.Text)`
+  ${({ theme }) => css`
+    padding: 0 ${theme.sizeUnit * 2}px;
+
+    && {
+      margin: 0;
+    }
+  `}
+`;
+
+const StyledDiscardBtn = styled(Button)`
+  ${({ theme }) => css`
+    min-width: ${theme.sizeUnit * 22}px;
+    height: ${theme.sizeUnit * 8}px;
+  `}
+`;
+
+const StyledSaveBtn = styled(Button)`
+  ${({ theme }) => css`
+    min-width: ${theme.sizeUnit * 17}px;
+    height: ${theme.sizeUnit * 8}px;
+    span > :first-of-type {
+      margin-right: 0;
+    }
+  `}
+`;
+
+const StyledWarningIcon = styled(Icons.WarningOutlined)`
+  ${({ theme }) => css`
+    color: ${theme.colors.warning.base};
+    margin-right: ${theme.sizeUnit * 4}px;
+  `}
+`;
+
+export type UnsavedChangesModalProps = {
+  showModal: boolean;
+  onHide: () => void;
+  handleSave: () => void;
+  onConfirmNavigation: () => void;
+  title?: string;
+  body?: string;
+};
+
+export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
+  showModal,
+  onHide,
+  handleSave,
+  onConfirmNavigation,
+  title = 'Unsaved Changes',
+  body = "If you don't save, changes will be lost.",
+}: UnsavedChangesModalProps): ReactElement => (
+  <Modal
+    centered
+    responsive
+    onHide={onHide}
+    show={showModal}
+    width="444px"
+    title={
+      <div
+        css={css`
+          align-items: center;
+          display: flex;
+        `}
+      >
+        <StyledWarningIcon iconSize="xl" />
+        <StyledModalTitle type="secondary" level={5}>
+          {title}
+        </StyledModalTitle>
+      </div>
+    }
+    footer={
+      <div
+        css={css`
+          display: flex;
+          justify-content: flex-end;
+          width: 100%;
+        `}
+      >
+        <StyledDiscardBtn
+          htmlType="button"
+          buttonSize="small"
+          onClick={onConfirmNavigation}
+        >
+          {t('Discard')}
+        </StyledDiscardBtn>
+        <StyledSaveBtn
+          htmlType="button"
+          buttonSize="small"
+          buttonStyle="primary"
+          onClick={handleSave}
+        >
+          {t('Save')}
+        </StyledSaveBtn>
+      </div>
+    }
+  >
+    <StyledModalBody type="secondary">{body}</StyledModalBody>
+  </Modal>
+);
diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/index.ts 
b/superset-frontend/packages/superset-ui-core/src/components/index.ts
index 6e5348f320..f7eda5e333 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts
@@ -163,5 +163,6 @@ export * from './Steps';
 export * from './Table';
 export * from './TableView';
 export * from './Tag';
+export * from './UnsavedChangesModal';
 export * from './constants';
 export * from './Result';
diff --git 
a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx 
b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx
deleted file mode 100644
index 267b50291c..0000000000
--- a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx
+++ /dev/null
@@ -1,279 +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 { render, screen, userEvent } from 'spec/helpers/testing-library';
-import {
-  AlteredSliceTag,
-  alterForComparison,
-  formatValueHandler,
-  isEqualish,
-} from '.';
-import { defaultProps } from './AlteredSliceTagMocks';
-
-const controlsMap = {
-  b: { type: 'BoundsControl', label: 'Bounds' },
-  column_collection: { type: 'CollectionControl', label: 'Collection' },
-  metrics: { type: 'MetricsControl', label: 'Metrics' },
-  adhoc_filters: { type: 'AdhocFilterControl', label: 'Adhoc' },
-  other_control: { type: 'OtherControl', label: 'Other' },
-};
-
-test('renders the "Altered" label', () => {
-  render(
-    <AlteredSliceTag
-      origFormData={defaultProps.origFormData}
-      currentFormData={defaultProps.currentFormData}
-    />,
-  );
-
-  const alteredLabel = screen.getByText('Altered');
-  expect(alteredLabel).toBeInTheDocument();
-});
-
-test('opens the modal on click', () => {
-  render(
-    <AlteredSliceTag
-      origFormData={defaultProps.origFormData}
-      currentFormData={defaultProps.currentFormData}
-    />,
-  );
-
-  const alteredLabel = screen.getByText('Altered');
-  userEvent.click(alteredLabel);
-
-  const modalTitle = screen.getByText('Chart changes');
-  expect(modalTitle).toBeInTheDocument();
-});
-
-test('displays the differences in the modal', () => {
-  render(
-    <AlteredSliceTag
-      origFormData={defaultProps.origFormData}
-      currentFormData={defaultProps.currentFormData}
-    />,
-  );
-
-  const alteredLabel = screen.getByText('Altered');
-  userEvent.click(alteredLabel);
-
-  const beforeValue = screen.getByText('1, 2, 3, 4');
-  const afterValue = screen.getByText('a, b, c, d');
-  expect(beforeValue).toBeInTheDocument();
-  expect(afterValue).toBeInTheDocument();
-});
-
-test('does not render anything if there are no differences', () => {
-  render(
-    <AlteredSliceTag
-      origFormData={defaultProps.origFormData}
-      currentFormData={defaultProps.origFormData}
-    />,
-  );
-
-  const alteredLabel = screen.queryByText('Altered');
-  expect(alteredLabel).not.toBeInTheDocument();
-});
-
-test('alterForComparison returns null for undefined value', () => {
-  expect(alterForComparison(undefined)).toBeNull();
-});
-
-test('alterForComparison returns null for null value', () => {
-  expect(alterForComparison(null)).toBeNull();
-});
-
-test('alterForComparison returns null for empty string value', () => {
-  expect(alterForComparison('')).toBeNull();
-});
-
-test('alterForComparison returns null for empty array value', () => {
-  expect(alterForComparison([])).toBeNull();
-});
-
-test('alterForComparison returns null for empty object value', () => {
-  expect(alterForComparison({})).toBeNull();
-});
-
-test('alterForComparison returns value for non-empty array', () => {
-  const value = [1, 2, 3];
-  expect(alterForComparison(value)).toEqual(value);
-});
-
-test('alterForComparison returns value for non-empty object', () => {
-  const value = { key: 'value' };
-  expect(alterForComparison(value)).toEqual(value);
-});
-
-test('formatValueHandler handles undefined value', () => {
-  const value = undefined;
-  const key = 'b';
-  const formattedValue = formatValueHandler(value, key, controlsMap);
-  expect(formattedValue).toBe('N/A');
-});
-
-test('formatValueHandler handles null value', () => {
-  const value = null;
-  const key = 'b';
-  const formattedValue = formatValueHandler(value, key, controlsMap);
-  expect(formattedValue).toBe('null');
-});
-
-test('formatValueHandler returns "[]" for empty filters', () => {
-  const value = [];
-  const key = 'adhoc_filters';
-  const formattedValue = formatValueHandler(value, key, controlsMap);
-  expect(formattedValue).toBe('[]');
-});
-
-test('formatValueHandler formats filters with array values', () => {
-  const filters = [
-    {
-      clause: 'WHERE',
-      comparator: ['1', 'g', '7', 'ho'],
-      expressionType: 'SIMPLE',
-      operator: 'IN',
-      subject: 'a',
-    },
-    {
-      clause: 'WHERE',
-      comparator: ['hu', 'ho', 'ha'],
-      expressionType: 'SIMPLE',
-      operator: 'NOT IN',
-      subject: 'b',
-    },
-  ];
-  const key = 'adhoc_filters';
-  const formattedValue = formatValueHandler(filters, key, controlsMap);
-  const expected = 'a IN [1, g, 7, ho], b NOT IN [hu, ho, ha]';
-  expect(formattedValue).toBe(expected);
-});
-
-test('formatValueHandler formats filters with string values', () => {
-  const filters = [
-    {
-      clause: 'WHERE',
-      comparator: 'gucci',
-      expressionType: 'SIMPLE',
-      operator: '==',
-      subject: 'a',
-    },
-    {
-      clause: 'WHERE',
-      comparator: 'moshi moshi',
-      expressionType: 'SIMPLE',
-      operator: 'LIKE',
-      subject: 'b',
-    },
-  ];
-  const key = 'adhoc_filters';
-  const expected = 'a == gucci, b LIKE moshi moshi';
-  const formattedValue = formatValueHandler(filters, key, controlsMap);
-  expect(formattedValue).toBe(expected);
-});
-
-test('formatValueHandler formats "Min" and "Max" for BoundsControl', () => {
-  const value = [1, 2];
-  const key = 'b';
-  const result = formatValueHandler(value, key, controlsMap);
-  expect(result).toEqual('Min: 1, Max: 2');
-});
-
-test('formatValueHandler formats stringified objects for CollectionControl', 
() => {
-  const value = [{ a: 1 }, { b: 2 }];
-  const key = 'column_collection';
-  const result = formatValueHandler(value, key, controlsMap);
-  expect(result).toEqual(
-    `${JSON.stringify(value[0])}, ${JSON.stringify(value[1])}`,
-  );
-});
-
-test('formatValueHandler formats MetricsControl values correctly', () => {
-  const value = [{ label: 'SUM(Sales)' }, { label: 'Metric2' }];
-  const key = 'metrics';
-  const result = formatValueHandler(value, key, controlsMap);
-  expect(result).toEqual('SUM(Sales), Metric2');
-});
-
-test('formatValueHandler formats boolean values as string', () => {
-  const value1 = true;
-  const value2 = false;
-  const key = 'b';
-  const formattedValue1 = formatValueHandler(value1, key, controlsMap);
-  const formattedValue2 = formatValueHandler(value2, key, controlsMap);
-  expect(formattedValue1).toBe('true');
-  expect(formattedValue2).toBe('false');
-});
-
-test('formatValueHandler formats array values correctly', () => {
-  const value = [
-    { label: 'Label1' },
-    { label: 'Label2' },
-    5,
-    6,
-    7,
-    8,
-    'hello',
-    'goodbye',
-  ];
-  const result = formatValueHandler(value, undefined, controlsMap);
-  const expected = 'Label1, Label2, 5, 6, 7, 8, hello, goodbye';
-  expect(result).toEqual(expected);
-});
-
-test('formatValueHandler formats string values correctly', () => {
-  const value = 'test';
-  const key = 'other_control';
-  const result = formatValueHandler(value, key, controlsMap);
-  expect(result).toEqual('test');
-});
-
-test('formatValueHandler formats number values correctly', () => {
-  const value = 123;
-  const key = 'other_control';
-  const result = formatValueHandler(value, key, controlsMap);
-  expect(result).toEqual(123);
-});
-
-test('formatValueHandler formats object values correctly', () => {
-  const value = { 1: 2, alpha: 'bravo' };
-  const key = 'other_control';
-  const expected = '{"1":2,"alpha":"bravo"}';
-  const result = formatValueHandler(value, key, controlsMap);
-  expect(result).toEqual(expected);
-});
-
-test('isEqualish considers null, undefined, {} and [] as equal', () => {
-  expect(isEqualish(null, undefined)).toBe(true);
-  expect(isEqualish(null, [])).toBe(true);
-  expect(isEqualish(null, {})).toBe(true);
-  expect(isEqualish(undefined, {})).toBe(true);
-});
-
-test('isEqualish considers empty strings equal to null', () => {
-  expect(isEqualish(undefined, '')).toBe(true);
-  expect(isEqualish(null, '')).toBe(true);
-});
-
-test('isEqualish considers deeply equal objects equal', () => {
-  const obj1 = { a: { b: { c: 1 } } };
-  const obj2 = { a: { b: { c: 1 } } };
-  expect(isEqualish('', '')).toBe(true);
-  expect(isEqualish(obj1, obj2)).toBe(true);
-  // Actually  not equal
-  expect(isEqualish({ a: 1, b: 2, z: 9 }, { a: 1, b: 2, c: 3 })).toBe(false);
-});
diff --git 
a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.tsx 
b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.tsx
new file mode 100644
index 0000000000..515b6d9fc0
--- /dev/null
+++ b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.tsx
@@ -0,0 +1,97 @@
+/**
+ * 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, userEvent } from 'spec/helpers/testing-library';
+import { QueryFormData } from '@superset-ui/core';
+import { AlteredSliceTag } from '.';
+import { defaultProps, expectedDiffs } from './AlteredSliceTagMocks';
+
+test('renders the "Altered" label', () => {
+  render(
+    <AlteredSliceTag
+      origFormData={defaultProps.origFormData}
+      currentFormData={defaultProps.currentFormData}
+      diffs={expectedDiffs}
+    />,
+  );
+
+  const alteredLabel: HTMLElement = screen.getByText('Altered');
+  expect(alteredLabel).toBeInTheDocument();
+});
+
+test('opens the modal on click', () => {
+  render(
+    <AlteredSliceTag
+      origFormData={defaultProps.origFormData}
+      currentFormData={defaultProps.currentFormData}
+      diffs={expectedDiffs}
+    />,
+  );
+
+  const alteredLabel: HTMLElement = screen.getByText('Altered');
+  userEvent.click(alteredLabel);
+
+  const modalTitle: HTMLElement = screen.getByText('Chart changes');
+
+  expect(modalTitle).toBeInTheDocument();
+});
+
+test('displays the differences in the modal', () => {
+  render(
+    <AlteredSliceTag
+      origFormData={defaultProps.origFormData}
+      currentFormData={defaultProps.currentFormData}
+      diffs={expectedDiffs}
+    />,
+  );
+
+  const alteredLabel: HTMLElement = screen.getByText('Altered');
+  userEvent.click(alteredLabel);
+
+  const beforeValue: HTMLElement = screen.getByText('1, 2, 3, 4');
+  const afterValue: HTMLElement = screen.getByText('a, b, c, d');
+
+  expect(beforeValue).toBeInTheDocument();
+  expect(afterValue).toBeInTheDocument();
+});
+
+test('does not render anything if there are no differences', () => {
+  render(
+    <AlteredSliceTag
+      origFormData={defaultProps.origFormData}
+      currentFormData={defaultProps.origFormData}
+      diffs={{}}
+    />,
+  );
+
+  const alteredLabel: HTMLElement | null = screen.queryByText('Altered');
+
+  expect(alteredLabel).not.toBeInTheDocument();
+});
+
+test('does not render the altered label if diffs is empty', () => {
+  render(
+    <AlteredSliceTag
+      origFormData={{ viz_type: 'altered_slice_tag_spec' } as QueryFormData}
+      currentFormData={{ viz_type: 'altered_slice_tag_spec' } as QueryFormData}
+      diffs={{}}
+    />,
+  );
+
+  expect(screen.queryByText('Altered')).not.toBeInTheDocument();
+});
diff --git 
a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTagMocks.ts 
b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTagMocks.ts
index c307eace57..1f54bdfbde 100644
--- a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTagMocks.ts
+++ b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTagMocks.ts
@@ -18,10 +18,16 @@
  */
 import { QueryFormData } from '@superset-ui/core';
 import { ControlPanelConfig } from '@superset-ui/chart-controls';
-import type { DiffType, RowType } from './types';
+import type { DiffType } from 'src/types/DiffType';
+import { getChartFormDiffs } from 'src/utils/getChartFormDiffs';
+import type { RowType } from './types';
 
-export const defaultProps: Record<string, Partial<QueryFormData>> = {
+export const defaultProps: {
+  origFormData: QueryFormData;
+  currentFormData: QueryFormData;
+} = {
   origFormData: {
+    datasource: '123__table',
     viz_type: 'altered_slice_tag_spec',
     adhoc_filters: [
       {
@@ -40,7 +46,10 @@ export const defaultProps: Record<string, 
Partial<QueryFormData>> = {
     never: 5,
     ever: { a: 'b', c: 'd' },
   },
+
   currentFormData: {
+    datasource: '123__table',
+    viz_type: 'altered_slice_tag_spec',
     adhoc_filters: [
       {
         clause: 'WHERE',
@@ -106,6 +115,7 @@ export const expectedDiffs: Record<string, DiffType> = {
     after: { x: 'y', z: 'z' },
   },
 };
+
 export const expectedRows: RowType[] = [
   {
     control: 'Fake Filters',
@@ -131,6 +141,7 @@ export const expectedRows: RowType[] = [
     after: '{"x":"y","z":"z"}',
   },
 ];
+
 export const fakePluginControls: ControlPanelConfig = {
   controlPanelSections: [
     {
@@ -175,3 +186,6 @@ export const fakePluginControls: ControlPanelConfig = {
     },
   ],
 };
+
+export const createDiffs = (original: QueryFormData, current: QueryFormData) =>
+  getChartFormDiffs(original, current);
diff --git a/superset-frontend/src/components/AlteredSliceTag/index.tsx 
b/superset-frontend/src/components/AlteredSliceTag/index.tsx
index 43fd0d7a4f..b9025fd2d2 100644
--- a/superset-frontend/src/components/AlteredSliceTag/index.tsx
+++ b/superset-frontend/src/components/AlteredSliceTag/index.tsx
@@ -16,13 +16,10 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useCallback, useEffect, useMemo, useState, FC } from 'react';
-
-import { isEqual, isEmpty } from 'lodash';
+import { useEffect, useMemo, useState, FC } from 'react';
+import { isEmpty } from 'lodash';
 import { t } from '@superset-ui/core';
-import { sanitizeFormData } from 'src/explore/exploreUtils/formData';
 import getControlsForVizType from 'src/utils/getControlsForVizType';
-import { safeStringify } from 'src/utils/safeStringify';
 import {
   Label,
   Icons,
@@ -30,130 +27,21 @@ import {
   ModalTrigger,
   TableView,
 } from '@superset-ui/core/components';
-import type {
-  AlteredSliceTagProps,
-  ControlMap,
-  DiffItemType,
-  DiffType,
-  FilterItemType,
-  RowType,
-} from './types';
-
-export const alterForComparison = (
-  value?: string | null | [],
-): string | null => {
-  // Treat `null`, `undefined`, and empty strings as equivalent
-  if (value === undefined || value === null || value === '') {
-    return null;
-  }
-  // Treat empty arrays and objects as equivalent to null
-  if (Array.isArray(value) && value.length === 0) {
-    return null;
-  }
-  if (typeof value === 'object' && Object.keys(value).length === 0) {
-    return null;
-  }
-  return value;
-};
-
-export const formatValueHandler = (
-  value: DiffItemType,
-  key: string,
-  controlsMap: ControlMap,
-): string | number => {
-  if (value === undefined) {
-    return 'N/A';
-  }
-  if (value === null) {
-    return 'null';
-  }
-  if (typeof value === 'boolean') {
-    return value ? 'true' : 'false';
-  }
-  if (controlsMap[key]?.type === 'AdhocFilterControl' && Array.isArray(value)) 
{
-    if (!value.length) {
-      return '[]';
-    }
-    return value
-      .map((v: FilterItemType) => {
-        const filterVal =
-          v.comparator && v.comparator.constructor === Array
-            ? `[${v.comparator.join(', ')}]`
-            : v.comparator;
-        return `${v.subject} ${v.operator} ${filterVal}`;
-      })
-      .join(', ');
-  }
-  if (controlsMap[key]?.type === 'BoundsControl' && Array.isArray(value)) {
-    return `Min: ${value[0]}, Max: ${value[1]}`;
-  }
-  if (controlsMap[key]?.type === 'CollectionControl' && Array.isArray(value)) {
-    return value.map((v: FilterItemType) => safeStringify(v)).join(', ');
-  }
-  if (
-    controlsMap[key]?.type === 'MetricsControl' &&
-    value.constructor === Array
-  ) {
-    const formattedValue = value.map((v: FilterItemType) => v?.label ?? v);
-    return formattedValue.length ? formattedValue.join(', ') : '[]';
-  }
-  if (Array.isArray(value)) {
-    const formattedValue = value.map((v: FilterItemType) => v?.label ?? v);
-    return formattedValue.length ? formattedValue.join(', ') : '[]';
-  }
-  if (typeof value === 'string' || typeof value === 'number') {
-    return value;
-  }
-  return safeStringify(value);
-};
-
-export const getRowsFromDiffs = (
-  diffs: { [key: string]: DiffType },
-  controlsMap: ControlMap,
-): RowType[] =>
-  Object.entries(diffs).map(([key, diff]) => ({
-    control: controlsMap[key]?.label || key,
-    before: formatValueHandler(diff.before, key, controlsMap),
-    after: formatValueHandler(diff.after, key, controlsMap),
-  }));
-
-export const isEqualish = (val1: string, val2: string): boolean =>
-  isEqual(alterForComparison(val1), alterForComparison(val2));
+import type { AlteredSliceTagProps, ControlMap, RowType } from './types';
+import { getRowsFromDiffs } from './utils';
 
 export const AlteredSliceTag: FC<AlteredSliceTagProps> = props => {
   const [rows, setRows] = useState<RowType[]>([]);
   const [hasDiffs, setHasDiffs] = useState<boolean>(false);
 
-  const getDiffs = useCallback(() => {
-    // Returns all properties that differ in the
-    // current form data and the saved form data
-    const ofd = sanitizeFormData(props.origFormData);
-    const cfd = sanitizeFormData(props.currentFormData);
-
-    const fdKeys = Object.keys(cfd);
-    const diffs: { [key: string]: DiffType } = {};
-    fdKeys.forEach(fdKey => {
-      if (!ofd[fdKey] && !cfd[fdKey]) {
-        return;
-      }
-      if (['filters', 'having', 'where'].includes(fdKey)) {
-        return;
-      }
-      if (!isEqualish(ofd[fdKey], cfd[fdKey])) {
-        diffs[fdKey] = { before: ofd[fdKey], after: cfd[fdKey] };
-      }
-    });
-    return diffs;
-  }, [props.currentFormData, props.origFormData]);
-
   useEffect(() => {
-    const diffs = getDiffs();
     const controlsMap = getControlsForVizType(
       props.origFormData?.viz_type,
     ) as ControlMap;
-    setRows(getRowsFromDiffs(diffs, controlsMap));
-    setHasDiffs(!isEmpty(diffs));
-  }, [getDiffs, props.origFormData?.viz_type]);
+
+    setRows(getRowsFromDiffs(props.diffs, controlsMap));
+    setHasDiffs(!isEmpty(props.diffs));
+  }, [props.diffs, props.origFormData?.viz_type]);
 
   const modalBody = useMemo(() => {
     const columns = [
diff --git a/superset-frontend/src/components/AlteredSliceTag/types.ts 
b/superset-frontend/src/components/AlteredSliceTag/types.ts
index e1f6108608..44c6796e36 100644
--- a/superset-frontend/src/components/AlteredSliceTag/types.ts
+++ b/superset-frontend/src/components/AlteredSliceTag/types.ts
@@ -17,8 +17,11 @@
  * under the License.
  */
 import type { QueryFormData } from '@superset-ui/core';
+import { DiffType } from 'src/types/DiffType';
 
 export interface AlteredSliceTagProps {
+  className?: string;
+  diffs: Record<string, DiffType>;
   origFormData: QueryFormData;
   currentFormData: QueryFormData;
 }
@@ -30,29 +33,6 @@ export interface ControlMap {
   };
 }
 
-export type FilterItemType = {
-  comparator?: string | string[];
-  subject: string;
-  operator: string;
-  label?: string;
-};
-
-export type DiffItemType<
-  T = FilterItemType | number | string | Record<string | number, any>,
-> =
-  | T[]
-  | boolean
-  | number
-  | string
-  | Record<string | number, any>
-  | null
-  | undefined;
-
-export type DiffType = {
-  before: DiffItemType;
-  after: DiffItemType;
-};
-
 export type RowType = {
   before: string | number;
   after: string | number;
diff --git a/superset-frontend/src/components/AlteredSliceTag/utils/index.ts 
b/superset-frontend/src/components/AlteredSliceTag/utils/index.ts
new file mode 100644
index 0000000000..482a433262
--- /dev/null
+++ b/superset-frontend/src/components/AlteredSliceTag/utils/index.ts
@@ -0,0 +1,107 @@
+/**
+ * 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 { DiffItemType, DiffType, FilterItemType } from 'src/types/DiffType';
+import { safeStringify } from 'src/utils/safeStringify';
+import { ControlMap, RowType } from '../types';
+
+export const alterForComparison = (value?: unknown): unknown => {
+  // Treat `null`, `undefined`, and empty strings as equivalent
+  if (value === undefined || value === null || value === '') return null;
+
+  // Treat empty arrays and objects as equivalent to null
+  if (Array.isArray(value) && value.length === 0) return null;
+
+  if (typeof value === 'object' && Object.keys(value).length === 0) return 
null;
+
+  return value;
+};
+
+export const formatValueHandler = (
+  value: DiffItemType,
+  key = '',
+  controlsMap: ControlMap,
+): string | number => {
+  if (value === undefined) return 'N/A';
+
+  if (value === null) return 'null';
+
+  if (typeof value === 'boolean') return value ? 'true' : 'false';
+
+  if (controlsMap[key]?.type === 'AdhocFilterControl' && Array.isArray(value)) 
{
+    if (!value.length) return '[]';
+
+    return value
+      .map((v: FilterItemType): string => {
+        const filterVal: string | string[] | undefined =
+          v.comparator && v.comparator.constructor === Array
+            ? `[${v.comparator.join(', ')}]`
+            : v.comparator;
+        return `${v.subject} ${v.operator} ${filterVal}`;
+      })
+      .join(', ');
+  }
+
+  if (controlsMap[key]?.type === 'BoundsControl' && Array.isArray(value))
+    return `Min: ${value[0]}, Max: ${value[1]}`;
+
+  if (controlsMap[key]?.type === 'CollectionControl' && Array.isArray(value))
+    return value
+      .map((v: FilterItemType): string => safeStringify(v))
+      .join(', ');
+
+  if (
+    controlsMap[key]?.type === 'MetricsControl' &&
+    value.constructor === Array
+  ) {
+    const formattedValue: (string | FilterItemType)[] = value.map(
+      (v: FilterItemType): string | FilterItemType => v?.label ?? v,
+    );
+    return formattedValue.length ? formattedValue.join(', ') : '[]';
+  }
+
+  if (Array.isArray(value)) {
+    const formattedValue = value.map((v: any) => {
+      if (
+        typeof v === 'object' &&
+        v !== null &&
+        'label' in v &&
+        typeof v.label === 'string'
+      )
+        return v.label;
+
+      return String(v);
+    });
+
+    return formattedValue.length ? formattedValue.join(', ') : '[]';
+  }
+
+  if (typeof value === 'string' || typeof value === 'number') return value;
+
+  return safeStringify(value);
+};
+
+export const getRowsFromDiffs = (
+  diffs: { [key: string]: DiffType },
+  controlsMap: ControlMap,
+): RowType[] =>
+  Object.entries(diffs).map(([key, diff]: [string, DiffType]) => ({
+    control: controlsMap[key]?.label || key,
+    before: formatValueHandler(diff.before, key, controlsMap),
+    after: formatValueHandler(diff.after, key, controlsMap),
+  }));
diff --git 
a/superset-frontend/src/components/AlteredSliceTag/utils/utils.test.ts 
b/superset-frontend/src/components/AlteredSliceTag/utils/utils.test.ts
new file mode 100644
index 0000000000..861686e7c4
--- /dev/null
+++ b/superset-frontend/src/components/AlteredSliceTag/utils/utils.test.ts
@@ -0,0 +1,291 @@
+/**
+ * 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 { alterForComparison, formatValueHandler, getRowsFromDiffs } from '.';
+import { RowType } from '../types';
+
+describe('alterForComparison', () => {
+  it('returns null for undefined value', () => {
+    expect(alterForComparison(undefined)).toBeNull();
+  });
+
+  it('returns null for null value', () => {
+    expect(alterForComparison(null)).toBeNull();
+  });
+
+  it('returns null for empty string value', () => {
+    expect(alterForComparison('')).toBeNull();
+  });
+
+  it('returns null for empty array value', () => {
+    expect(alterForComparison([])).toBeNull();
+  });
+
+  it('returns null for empty object value', () => {
+    expect(alterForComparison({})).toBeNull();
+  });
+
+  it('returns value for non-empty array', () => {
+    const value = [1, 2, 3];
+    expect(alterForComparison(value)).toEqual(value);
+  });
+
+  it('returns value for non-empty object', () => {
+    const value = { key: 'value' };
+    expect(alterForComparison(value)).toEqual(value);
+  });
+});
+
+describe('formatValueHandler', () => {
+  const controlsMap = {
+    b: { type: 'BoundsControl', label: 'Bounds' },
+    column_collection: { type: 'CollectionControl', label: 'Collection' },
+    metrics: { type: 'MetricsControl', label: 'Metrics' },
+    adhoc_filters: { type: 'AdhocFilterControl', label: 'Adhoc' },
+    other_control: { type: 'OtherControl', label: 'Other' },
+  };
+
+  it('handles undefined value', () => {
+    const value = undefined;
+    const key = 'b';
+
+    const formattedValue: string | number = formatValueHandler(
+      value,
+      key,
+      controlsMap,
+    );
+
+    expect(formattedValue).toBe('N/A');
+  });
+
+  it('handles null value', () => {
+    const value = null;
+    const key = 'b';
+
+    const formattedValue: string | number = formatValueHandler(
+      value,
+      key,
+      controlsMap,
+    );
+
+    expect(formattedValue).toBe('null');
+  });
+
+  it('returns "[]" for empty filters', () => {
+    const value: unknown[] = [];
+    const key = 'adhoc_filters';
+
+    const formattedValue: string | number = formatValueHandler(
+      value,
+      key,
+      controlsMap,
+    );
+
+    expect(formattedValue).toBe('[]');
+  });
+
+  it('formats filters with array values', () => {
+    const filters = [
+      {
+        clause: 'WHERE',
+        comparator: ['1', 'g', '7', 'ho'],
+        expressionType: 'SIMPLE',
+        operator: 'IN',
+        subject: 'a',
+      },
+      {
+        clause: 'WHERE',
+        comparator: ['hu', 'ho', 'ha'],
+        expressionType: 'SIMPLE',
+        operator: 'NOT IN',
+        subject: 'b',
+      },
+    ];
+    const key = 'adhoc_filters';
+
+    const expected = 'a IN [1, g, 7, ho], b NOT IN [hu, ho, ha]';
+    const formattedValue: string | number = formatValueHandler(
+      filters,
+      key,
+      controlsMap,
+    );
+
+    expect(formattedValue).toBe(expected);
+  });
+
+  it('formats filters with string values', () => {
+    const filters = [
+      {
+        clause: 'WHERE',
+        comparator: 'gucci',
+        expressionType: 'SIMPLE',
+        operator: '==',
+        subject: 'a',
+      },
+      {
+        clause: 'WHERE',
+        comparator: 'moshi moshi',
+        expressionType: 'SIMPLE',
+        operator: 'LIKE',
+        subject: 'b',
+      },
+    ];
+
+    const key = 'adhoc_filters';
+    const expected = 'a == gucci, b LIKE moshi moshi';
+    const formattedValue: string | number = formatValueHandler(
+      filters,
+      key,
+      controlsMap,
+    );
+
+    expect(formattedValue).toBe(expected);
+  });
+
+  it('formats "Min" and "Max" for BoundsControl', () => {
+    const value: number[] = [1, 2];
+    const key = 'b';
+
+    const result: string | number = formatValueHandler(value, key, 
controlsMap);
+
+    expect(result).toEqual('Min: 1, Max: 2');
+  });
+
+  it('formats stringified objects for CollectionControl', () => {
+    const value = [{ a: 1 }, { b: 2 }];
+    const key = 'column_collection';
+
+    const result: string | number = formatValueHandler(value, key, 
controlsMap);
+
+    expect(result).toEqual(
+      `${JSON.stringify(value[0])}, ${JSON.stringify(value[1])}`,
+    );
+  });
+
+  it('formats MetricsControl values correctly', () => {
+    const value = [{ label: 'SUM(Sales)' }, { label: 'Metric2' }];
+    const key = 'metrics';
+
+    const result: string | number = formatValueHandler(value, key, 
controlsMap);
+
+    expect(result).toEqual('SUM(Sales), Metric2');
+  });
+
+  it('formats boolean values as string', () => {
+    const value1 = true;
+    const value2 = false;
+    const key = 'b';
+
+    const formattedValue1: string | number = formatValueHandler(
+      value1,
+      key,
+      controlsMap,
+    );
+    const formattedValue2: string | number = formatValueHandler(
+      value2,
+      key,
+      controlsMap,
+    );
+
+    expect(formattedValue1).toBe('true');
+    expect(formattedValue2).toBe('false');
+  });
+
+  it('formats array values correctly', () => {
+    const value = [
+      { label: 'Label1' },
+      { label: 'Label2' },
+      5,
+      6,
+      7,
+      8,
+      'hello',
+      'goodbye',
+    ];
+
+    const expected = 'Label1, Label2, 5, 6, 7, 8, hello, goodbye';
+    const result: string | number = formatValueHandler(
+      value,
+      undefined,
+      controlsMap,
+    );
+
+    expect(result).toEqual(expected);
+  });
+
+  it('formats string values correctly', () => {
+    const value = 'test';
+    const key = 'other_control';
+
+    const result: string | number = formatValueHandler(value, key, 
controlsMap);
+
+    expect(result).toEqual('test');
+  });
+
+  it('formats number values correctly', () => {
+    const value = 123;
+    const key = 'other_control';
+
+    const result: string | number = formatValueHandler(value, key, 
controlsMap);
+
+    expect(result).toEqual(123);
+  });
+
+  it('formats object values correctly', () => {
+    const value = { 1: 2, alpha: 'bravo' };
+    const key = 'other_control';
+    const expected = '{"1":2,"alpha":"bravo"}';
+
+    const result: string | number = formatValueHandler(value, key, 
controlsMap);
+
+    expect(result).toEqual(expected);
+  });
+});
+
+describe('getRowsFromDiffs', () => {
+  it('returns formatted rows for diffs', () => {
+    const diffs = {
+      metric: { before: [{ label: 'old' }], after: [{ label: 'new' }] },
+      limit: { before: 10, after: 20 },
+    };
+
+    const controlsMap = {
+      metric: { label: 'Metric', type: 'MetricsControl' },
+      limit: { label: 'Row Limit', type: 'TextControl' },
+    };
+
+    const rows: RowType[] = getRowsFromDiffs(diffs, controlsMap);
+
+    expect(rows).toEqual([
+      { control: 'Metric', before: 'old', after: 'new' },
+      { control: 'Row Limit', before: 10, after: 20 },
+    ]);
+  });
+
+  it('falls back to key if label is missing', () => {
+    const diffs = {
+      unknown: { before: 'a', after: 'b' },
+    };
+
+    const controlsMap = {};
+    const rows: RowType[] = getRowsFromDiffs(diffs, controlsMap);
+
+    expect(rows).toEqual([{ control: 'unknown', before: 'a', after: 'b' }]);
+  });
+});
diff --git a/superset-frontend/src/dashboard/components/Header/Header.test.tsx 
b/superset-frontend/src/dashboard/components/Header/Header.test.tsx
index ac567ad6fc..36ee0c7696 100644
--- a/superset-frontend/src/dashboard/components/Header/Header.test.tsx
+++ b/superset-frontend/src/dashboard/components/Header/Header.test.tsx
@@ -17,11 +17,13 @@
  * under the License.
  */
 import * as redux from 'redux';
+import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
 import {
   render,
   screen,
   fireEvent,
   userEvent,
+  within,
 } from 'spec/helpers/testing-library';
 import fetchMock from 'fetch-mock';
 import { getExtensionsRegistry, JsonObject } from '@superset-ui/core';
@@ -166,6 +168,10 @@ const onRefresh = jest.fn();
 const dashboardInfoChanged = jest.fn();
 const dashboardTitleChanged = jest.fn();
 
+jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
+  useUnsavedChangesPrompt: jest.fn(),
+}));
+
 beforeAll(() => {
   jest.spyOn(redux, 'bindActionCreators').mockImplementation(() => ({
     addSuccessToast,
@@ -195,9 +201,14 @@ beforeAll(() => {
 
 beforeEach(() => {
   jest.clearAllMocks();
-});
 
-beforeEach(() => {
+  (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+    showModal: false,
+    setShowModal: jest.fn(),
+    handleConfirmNavigation: jest.fn(),
+    handleSaveAndCloseModal: jest.fn(),
+  });
+
   window.history.pushState({}, 'Test page', '/dashboard?standalone=1');
 });
 
@@ -484,3 +495,91 @@ test('should render MetadataBar when not in edit mode and 
not embedded', () => {
     screen.getByText(state.dashboardInfo.changed_on_delta_humanized),
   ).toBeInTheDocument();
 });
+
+test('should show UnsavedChangesModal when there are unsaved changes and user 
tries to navigate', async () => {
+  (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+    showModal: true,
+    setShowModal: jest.fn(),
+    handleConfirmNavigation: jest.fn(),
+    handleSaveAndCloseModal: jest.fn(),
+  });
+
+  setup({ ...editableState });
+
+  const modalTitle: HTMLElement = await screen.findByText(
+    'Save changes to your dashboard?',
+  );
+
+  const modalBody: HTMLElement = await screen.findByText(
+    "If you don't save, changes will be lost.",
+  );
+
+  expect(modalTitle).toBeInTheDocument();
+  expect(modalBody).toBeInTheDocument();
+});
+
+test('should call handleSaveAndCloseModal when Save is clicked in 
UnsavedChangesModal', async () => {
+  const handleSaveAndCloseModal = jest.fn();
+
+  (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+    showModal: true,
+    setShowModal: jest.fn(),
+    handleConfirmNavigation: jest.fn(),
+    handleSaveAndCloseModal,
+  });
+
+  setup({ ...editableState });
+
+  const modal: HTMLElement = await screen.findByRole('dialog');
+  const saveButton: HTMLElement = within(modal).getByRole('button', {
+    name: /save/i,
+  });
+
+  userEvent.click(saveButton);
+
+  expect(handleSaveAndCloseModal).toHaveBeenCalled();
+});
+
+test('should call handleConfirmNavigation when user confirms navigation in 
UnsavedChangesModal', async () => {
+  const handleConfirmNavigation = jest.fn();
+
+  (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+    showModal: true,
+    setShowModal: jest.fn(),
+    handleConfirmNavigation,
+    handleSaveAndCloseModal: jest.fn(),
+  });
+
+  setup({ ...editableState });
+
+  const modal: HTMLElement = await screen.findByRole('dialog');
+  const discardButton: HTMLElement = within(modal).getByRole('button', {
+    name: /discard/i,
+  });
+
+  userEvent.click(discardButton);
+
+  expect(handleConfirmNavigation).toHaveBeenCalled();
+});
+
+test('should call setShowUnsavedChangesModal(false) on cancel', async () => {
+  const setShowModal = jest.fn();
+
+  (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+    showModal: true,
+    setShowModal,
+    handleConfirmNavigation: jest.fn(),
+    handleSaveAndCloseModal: jest.fn(),
+  });
+
+  setup({ ...editableState });
+
+  const modal: HTMLElement = await screen.findByRole('dialog');
+  const closeButton: HTMLElement = within(modal).getByRole('button', {
+    name: /close/i,
+  });
+
+  userEvent.click(closeButton);
+
+  expect(setShowModal).toHaveBeenCalledWith(false);
+});
diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx 
b/superset-frontend/src/dashboard/components/Header/index.jsx
index b677a8c5ec..f4535c4de3 100644
--- a/superset-frontend/src/dashboard/components/Header/index.jsx
+++ b/superset-frontend/src/dashboard/components/Header/index.jsx
@@ -36,7 +36,12 @@ import {
   LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD,
 } from 'src/logger/LogUtils';
 import { Icons } from '@superset-ui/core/components/Icons';
-import { Button, Tooltip, DeleteModal } from '@superset-ui/core/components';
+import {
+  Button,
+  Tooltip,
+  DeleteModal,
+  UnsavedChangesModal,
+} from '@superset-ui/core/components';
 import { findPermission } from 'src/utils/findPermission';
 import { safeStringify } from 'src/utils/safeStringify';
 import PublishedStatus from 'src/dashboard/components/PublishedStatus';
@@ -54,6 +59,7 @@ import setPeriodicRunner, {
 import ReportModal from 'src/features/reports/ReportModal';
 import { deleteActiveReport } from 'src/features/reports/ReportModal/actions';
 import { PageHeaderWithActions } from 
'@superset-ui/core/components/PageHeaderWithActions';
+import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
 import DashboardEmbedModal from '../EmbeddedModal';
 import OverwriteConfirm from '../OverwriteConfirm';
 import {
@@ -460,6 +466,16 @@ const Header = () => {
     slug,
   ]);
 
+  const {
+    showModal: showUnsavedChangesModal,
+    setShowModal: setShowUnsavedChangesModal,
+    handleConfirmNavigation,
+    handleSaveAndCloseModal,
+  } = useUnsavedChangesPrompt({
+    hasUnsavedChanges,
+    onSave: overwriteDashboard,
+  });
+
   const showPropertiesModal = useCallback(() => {
     setShowingPropertiesModal(true);
   }, []);
@@ -816,6 +832,15 @@ const Header = () => {
           }
         `}
       />
+
+      <UnsavedChangesModal
+        title={t('Save changes to your dashboard?')}
+        body={t("If you don't save, changes will be lost.")}
+        showModal={showUnsavedChangesModal}
+        onHide={() => setShowUnsavedChangesModal(false)}
+        onConfirmNavigation={handleConfirmNavigation}
+        handleSave={handleSaveAndCloseModal}
+      />
     </div>
   );
 };
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
index 4f993d0158..886aee28d6 100644
--- 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
@@ -305,12 +305,19 @@ test.skip('validates the default value', async () => {
 });
 
 test('validates the pre-filter value', async () => {
+  jest.useFakeTimers();
+
   defaultRender();
+
   userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX));
   userEvent.click(getCheckbox(PRE_FILTER_REGEX));
-  expect(
-    await screen.findByText(PRE_FILTER_REQUIRED_REGEX),
-  ).toBeInTheDocument();
+
+  jest.runOnlyPendingTimers();
+  jest.useRealTimers();
+
+  await waitFor(() => {
+    expect(screen.getByText(PRE_FILTER_REQUIRED_REGEX)).toBeInTheDocument();
+  });
 });
 
 // eslint-disable-next-line jest/no-disabled-tests
diff --git 
a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
 
b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
index 9888493cf9..6f083fc9bf 100644
--- 
a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
+++ 
b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
@@ -23,6 +23,7 @@ import {
   screen,
   userEvent,
   waitFor,
+  within,
 } from 'spec/helpers/testing-library';
 import fetchMock from 'fetch-mock';
 import * as chartAction from 'src/components/Chart/chartAction';
@@ -30,6 +31,7 @@ import * as saveModalActions from 
'src/explore/actions/saveModalActions';
 import * as downloadAsImage from 'src/utils/downloadAsImage';
 import * as exploreUtils from 'src/explore/exploreUtils';
 import { FeatureFlag, VizType } from '@superset-ui/core';
+import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
 import ExploreHeader from '.';
 
 const chartEndpoint = 'glob:*api/v1/chart/*';
@@ -40,6 +42,10 @@ window.featureFlags = {
   [FeatureFlag.EmbeddableCharts]: true,
 };
 
+jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
+  useUnsavedChangesPrompt: jest.fn(),
+}));
+
 const createProps = (additionalProps = {}) => ({
   chart: {
     id: 1,
@@ -134,6 +140,18 @@ fetchMock.post(
 describe('ExploreChartHeader', () => {
   jest.setTimeout(15000); // ✅ Applies to all tests in this suite
 
+  beforeEach(() => {
+    jest.clearAllMocks();
+
+    (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+      showModal: false,
+      setShowModal: jest.fn(),
+      handleConfirmNavigation: jest.fn(),
+      handleSaveAndCloseModal: jest.fn(),
+      triggerManualSave: jest.fn(),
+    });
+  });
+
   test('Cancelling changes to the properties should reset previous 
properties', async () => {
     const props = createProps();
     render(<ExploreHeader {...props} />, { useRedux: true });
@@ -201,35 +219,173 @@ describe('ExploreChartHeader', () => {
   });
 
   test('Save chart', async () => {
-    const setSaveChartModalVisibility = jest.spyOn(
+    const setSaveChartModalVisibilitySpy = jest.spyOn(
       saveModalActions,
       'setSaveChartModalVisibility',
     );
+
+    const setSaveChartModalVisibilityMock =
+      setSaveChartModalVisibilitySpy as jest.Mock;
+
+    const triggerManualSave = jest.fn(() => {
+      setSaveChartModalVisibilityMock(true);
+    });
+
+    (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+      showModal: false,
+      setShowModal: jest.fn(),
+      handleConfirmNavigation: jest.fn(),
+      handleSaveAndCloseModal: jest.fn(),
+      triggerManualSave,
+    });
+
     const props = createProps();
     render(<ExploreHeader {...props} />, { useRedux: true });
-    expect(await screen.findByText('Save')).toBeInTheDocument();
-    userEvent.click(screen.getByText('Save'));
-    expect(setSaveChartModalVisibility).toHaveBeenCalledWith(true);
-    setSaveChartModalVisibility.mockClear();
+
+    const saveButton: HTMLElement = await screen.findByRole('button', {
+      name: /save/i,
+    });
+
+    userEvent.click(saveButton);
+
+    expect(triggerManualSave).toHaveBeenCalled();
+    expect(setSaveChartModalVisibilityMock).toHaveBeenCalledWith(true);
+
+    setSaveChartModalVisibilityMock.mockClear();
   });
 
   test('Save disabled', async () => {
-    const setSaveChartModalVisibility = jest.spyOn(
-      saveModalActions,
-      'setSaveChartModalVisibility',
-    );
+    const triggerManualSave = jest.fn();
+
+    (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+      showModal: false,
+      setShowModal: jest.fn(),
+      handleConfirmNavigation: jest.fn(),
+      handleSaveAndCloseModal: jest.fn(),
+      triggerManualSave,
+    });
+
     const props = createProps();
     render(<ExploreHeader {...props} saveDisabled />, { useRedux: true });
-    expect(await screen.findByText('Save')).toBeInTheDocument();
-    userEvent.click(screen.getByText('Save'));
-    expect(setSaveChartModalVisibility).not.toHaveBeenCalled();
-    setSaveChartModalVisibility.mockClear();
+
+    const saveButton: HTMLElement = await screen.findByRole('button', {
+      name: /save/i,
+    });
+
+    expect(saveButton).toBeDisabled();
+
+    userEvent.click(saveButton);
+
+    expect(triggerManualSave).not.toHaveBeenCalled();
+  });
+
+  test('should render UnsavedChangesModal when showModal is true', async () => 
{
+    const props = createProps();
+
+    (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+      showModal: true,
+      setShowModal: jest.fn(),
+      handleConfirmNavigation: jest.fn(),
+      handleSaveAndCloseModal: jest.fn(),
+      triggerManualSave: jest.fn(),
+    });
+
+    render(<ExploreHeader {...props} />, { useRedux: true });
+
+    expect(await screen.findByRole('dialog')).toBeInTheDocument();
+    expect(
+      await screen.findByText('Save changes to your chart?'),
+    ).toBeInTheDocument();
+    expect(
+      await screen.findByText("If you don't save, changes will be lost."),
+    ).toBeInTheDocument();
+  });
+
+  test('should call handleSaveAndCloseModal when clicking Save in 
UnsavedChangesModal', async () => {
+    const handleSaveAndCloseModal = jest.fn();
+
+    (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+      showModal: true,
+      setShowModal: jest.fn(),
+      handleConfirmNavigation: jest.fn(),
+      handleSaveAndCloseModal,
+      triggerManualSave: jest.fn(),
+    });
+
+    const props = createProps();
+    render(<ExploreHeader {...props} />, { useRedux: true });
+
+    const modal: HTMLElement = await screen.findByRole('dialog');
+    const saveButton: HTMLElement = within(modal).getByRole('button', {
+      name: /save/i,
+    });
+
+    userEvent.click(saveButton);
+
+    expect(handleSaveAndCloseModal).toHaveBeenCalled();
+  });
+
+  test('should call handleConfirmNavigation when clicking Discard in 
UnsavedChangesModal', async () => {
+    const handleConfirmNavigation = jest.fn();
+
+    (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+      showModal: true,
+      setShowModal: jest.fn(),
+      handleConfirmNavigation,
+      handleSaveAndCloseModal: jest.fn(),
+      triggerManualSave: jest.fn(),
+    });
+
+    const props = createProps();
+    render(<ExploreHeader {...props} />, { useRedux: true });
+
+    const modal: HTMLElement = await screen.findByRole('dialog');
+    const discardButton: HTMLElement = within(modal).getByRole('button', {
+      name: /discard/i,
+    });
+
+    userEvent.click(discardButton);
+
+    expect(handleConfirmNavigation).toHaveBeenCalled();
+  });
+
+  test('should call setShowModal(false) when clicking close button in 
UnsavedChangesModal', async () => {
+    const setShowModal = jest.fn();
+
+    (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+      showModal: true,
+      setShowModal,
+      handleConfirmNavigation: jest.fn(),
+      handleSaveAndCloseModal: jest.fn(),
+      triggerManualSave: jest.fn(),
+    });
+
+    const props = createProps();
+    render(<ExploreHeader {...props} />, { useRedux: true });
+
+    const closeButton: HTMLElement = await screen.findByRole('button', {
+      name: /close/i,
+    });
+
+    userEvent.click(closeButton);
+
+    expect(setShowModal).toHaveBeenCalledWith(false);
   });
 });
 
 describe('Additional actions tests', () => {
   jest.setTimeout(15000); // ✅ Applies to all tests in this suite
 
+  beforeEach(() => {
+    (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+      showModal: false,
+      setShowModal: jest.fn(),
+      handleConfirmNavigation: jest.fn(),
+      handleSaveAndCloseModal: jest.fn(),
+      triggerManualSave: jest.fn(),
+    });
+  });
+
   test('Should render a button', async () => {
     const props = createProps();
     render(<ExploreHeader {...props} />, { useRedux: true });
@@ -356,6 +512,14 @@ describe('Additional actions tests', () => {
     beforeEach(() => {
       spyDownloadAsImage = sinon.spy(downloadAsImage, 'default');
       spyExportChart = sinon.spy(exploreUtils, 'exportChart');
+
+      (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+        showModal: false,
+        setShowModal: jest.fn(),
+        handleConfirmNavigation: jest.fn(),
+        handleSaveAndCloseModal: jest.fn(),
+        triggerManualSave: jest.fn(),
+      });
     });
 
     afterEach(async () => {
diff --git 
a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx 
b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx
index 2492d94a2e..f90f37c49c 100644
--- a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx
+++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx
@@ -16,11 +16,16 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useHistory } from 'react-router-dom';
 import { useDispatch } from 'react-redux';
 import PropTypes from 'prop-types';
-import { Tooltip, Button, DeleteModal } from '@superset-ui/core/components';
+import {
+  Tooltip,
+  Button,
+  DeleteModal,
+  UnsavedChangesModal,
+} from '@superset-ui/core/components';
 import { AlteredSliceTag } from 'src/components';
 import { css, logging, SupersetClient, t } from '@superset-ui/core';
 import { chartPropShape } from 'src/dashboard/util/propShapes';
@@ -32,6 +37,8 @@ import { setSaveChartModalVisibility } from 
'src/explore/actions/saveModalAction
 import { applyColors, resetColors } from 'src/utils/colorScheme';
 import ReportModal from 'src/features/reports/ReportModal';
 import { deleteActiveReport } from 'src/features/reports/ReportModal/actions';
+import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
+import { getChartFormDiffs } from 'src/utils/getChartFormDiffs';
 import { useExploreAdditionalActionsMenu } from 
'../useExploreAdditionalActionsMenu';
 import { useExploreMetadataBar } from './useExploreMetadataBar';
 
@@ -50,6 +57,7 @@ const propTypes = {
   timeout: PropTypes.number,
   chart: chartPropShape,
   saveDisabled: PropTypes.bool,
+  isSaveModalVisible: PropTypes.bool,
 };
 
 const saveButtonStyles = theme => css`
@@ -83,13 +91,16 @@ export const ExploreChartHeader = ({
   sliceName,
   saveDisabled,
   metadata,
+  isSaveModalVisible,
 }) => {
   const dispatch = useDispatch();
   const { latestQueryFormData, sliceFormData } = chart;
   const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false);
   const [isReportModalOpen, setIsReportModalOpen] = useState(false);
   const [currentReportDeleting, setCurrentReportDeleting] = useState(null);
-  const updateCategoricalNamespace = async () => {
+  const [shouldForceCloseModal, setShouldForceCloseModal] = useState(false);
+
+  const updateCategoricalNamespace = useCallback(async () => {
     const { dashboards } = metadata || {};
     const dashboard =
       dashboardId && dashboards && dashboards.find(d => d.id === dashboardId);
@@ -117,11 +128,11 @@ export const ExploreChartHeader = ({
         logging.info(t('Unable to retrieve dashboard colors'));
       }
     }
-  };
+  }, [dashboardColorScheme, dashboardId, metadata]);
 
   useEffect(() => {
     updateCategoricalNamespace();
-  }, []);
+  }, [updateCategoricalNamespace]);
 
   const openPropertiesModal = () => {
     setIsPropertiesModalOpen(true);
@@ -139,10 +150,6 @@ export const ExploreChartHeader = ({
     setIsReportModalOpen(false);
   };
 
-  const showModal = useCallback(() => {
-    dispatch(setSaveChartModalVisibility(true));
-  }, [dispatch]);
-
   const updateSlice = useCallback(
     slice => {
       dispatch(sliceUpdated(slice));
@@ -179,8 +186,53 @@ export const ExploreChartHeader = ({
     );
 
   const metadataBar = useExploreMetadataBar(metadata, slice);
-
   const oldSliceName = slice?.slice_name;
+
+  const originalFormData = useMemo(() => {
+    if (!sliceFormData) return {};
+    return {
+      ...sliceFormData,
+      chartTitle: oldSliceName,
+    };
+  }, [sliceFormData, oldSliceName]);
+
+  const currentFormData = useMemo(
+    () => ({ ...formData, chartTitle: sliceName }),
+    [formData, sliceName],
+  );
+
+  const formDiffs = useMemo(
+    () => getChartFormDiffs(originalFormData, currentFormData),
+    [originalFormData, currentFormData],
+  );
+
+  const {
+    showModal: showUnsavedChangesModal,
+    setShowModal: setShowUnsavedChangesModal,
+    handleConfirmNavigation,
+    handleSaveAndCloseModal,
+    triggerManualSave,
+  } = useUnsavedChangesPrompt({
+    hasUnsavedChanges: Object.keys(formDiffs).length > 0,
+    onSave: () => dispatch(setSaveChartModalVisibility(true)),
+    isSaveModalVisible,
+    manualSaveOnUnsavedChanges: true,
+  });
+
+  const showModal = useCallback(() => {
+    triggerManualSave();
+  }, [triggerManualSave]);
+
+  useEffect(() => {
+    if (!isSaveModalVisible) setShouldForceCloseModal(true);
+  }, [isSaveModalVisible, setShowUnsavedChangesModal]);
+
+  useEffect(() => {
+    if (!showUnsavedChangesModal && shouldForceCloseModal) {
+      setShouldForceCloseModal(false);
+    }
+  }, [showUnsavedChangesModal, shouldForceCloseModal]);
+
   return (
     <>
       <PageHeaderWithActions
@@ -212,11 +264,9 @@ export const ExploreChartHeader = ({
             {sliceFormData ? (
               <AlteredSliceTag
                 className="altered"
-                origFormData={{
-                  ...sliceFormData,
-                  chartTitle: oldSliceName,
-                }}
-                currentFormData={{ ...formData, chartTitle: sliceName }}
+                diffs={formDiffs}
+                origFormData={originalFormData}
+                currentFormData={currentFormData}
               />
             ) : null}
             {metadataBar}
@@ -286,6 +336,15 @@ export const ExploreChartHeader = ({
           title={t('Delete Report?')}
         />
       )}
+
+      <UnsavedChangesModal
+        title={t('Save changes to your chart?')}
+        body={t("If you don't save, changes will be lost.")}
+        showModal={showUnsavedChangesModal}
+        onHide={() => setShowUnsavedChangesModal(false)}
+        onConfirmNavigation={handleConfirmNavigation}
+        handleSave={handleSaveAndCloseModal}
+      />
     </>
   );
 };
diff --git 
a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx 
b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
index 497cb48730..6f4b01c2cb 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
@@ -593,6 +593,7 @@ function ExploreViewContainer(props) {
         reports={props.reports}
         saveDisabled={errorMessage || props.chart.chartStatus === 'loading'}
         metadata={props.metadata}
+        isSaveModalVisible={props.isSaveModalVisible}
       />
       <ExplorePanelContainer id="explore-container">
         <Global
diff --git a/superset-frontend/src/explore/exploreUtils/formData.test.ts 
b/superset-frontend/src/explore/exploreUtils/formData.test.ts
index f14455b51b..c61b437037 100644
--- a/superset-frontend/src/explore/exploreUtils/formData.test.ts
+++ b/superset-frontend/src/explore/exploreUtils/formData.test.ts
@@ -16,13 +16,83 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { sanitizeFormData } from './formData';
-
-test('sanitizeFormData removes temporary control values', () => {
-  expect(
-    sanitizeFormData({
-      url_params: { foo: 'bar' },
-      metrics: ['foo', 'bar'],
-    }),
-  ).toEqual({ metrics: ['foo', 'bar'] });
+import { SupersetClient } from '@superset-ui/core';
+import { postFormData, putFormData } from './formData';
+
+jest.mock('@superset-ui/core', () => ({
+  SupersetClient: {
+    post: jest.fn(),
+    put: jest.fn(),
+  },
+}));
+
+test('postFormData should call SupersetClient.post with correct payload and 
return key', async () => {
+  const mockKey = '123abc';
+  const mockResponse = { json: { key: mockKey } };
+
+  (SupersetClient.post as jest.Mock).mockResolvedValue(mockResponse);
+
+  const result = await postFormData(
+    1,
+    'table',
+    { some: 'form', data: true },
+    42,
+    'tab-7',
+  );
+
+  expect(SupersetClient.post).toHaveBeenCalledWith({
+    endpoint: 'api/v1/explore/form_data?tab_id=tab-7',
+    jsonPayload: {
+      datasource_id: 1,
+      datasource_type: 'table',
+      form_data: JSON.stringify({ some: 'form', data: true }), // assuming 
sanitizeFormData is a passthrough
+      chart_id: 42,
+    },
+  });
+
+  expect(result).toBe(mockKey);
+});
+
+test('putFormData should call SupersetClient.put with correct payload and 
return message', async () => {
+  const mockMessage = 'Form data updated';
+  const mockResponse = { json: { message: mockMessage } };
+
+  (SupersetClient.put as jest.Mock).mockResolvedValue(mockResponse);
+
+  const result = await putFormData(
+    10,
+    'druid',
+    'abc123',
+    { another: 'value' },
+    undefined,
+    'tab-5',
+  );
+
+  expect(SupersetClient.put).toHaveBeenCalledWith({
+    endpoint: 'api/v1/explore/form_data/abc123?tab_id=tab-5',
+    jsonPayload: {
+      datasource_id: 10,
+      datasource_type: 'druid',
+      form_data: JSON.stringify({ another: 'value' }), // again, assuming 
sanitizeFormData is passthrough
+    },
+  });
+
+  expect(result).toBe(mockMessage);
+});
+
+test('postFormData without optional params should work', async () => {
+  (SupersetClient.post as jest.Mock).mockResolvedValue({
+    json: { key: 'abc' },
+  });
+
+  await postFormData(2, 'table', { foo: 'bar' });
+
+  expect(SupersetClient.post).toHaveBeenCalledWith({
+    endpoint: 'api/v1/explore/form_data',
+    jsonPayload: {
+      datasource_id: 2,
+      datasource_type: 'table',
+      form_data: JSON.stringify({ foo: 'bar' }),
+    },
+  });
 });
diff --git a/superset-frontend/src/explore/exploreUtils/formData.ts 
b/superset-frontend/src/explore/exploreUtils/formData.ts
index 36de6640a5..9a83d8fd8d 100644
--- a/superset-frontend/src/explore/exploreUtils/formData.ts
+++ b/superset-frontend/src/explore/exploreUtils/formData.ts
@@ -16,8 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { omit } from 'lodash';
-import { SupersetClient, JsonObject } from '@superset-ui/core';
+import { SupersetClient, JsonObject, JsonResponse } from '@superset-ui/core';
+import { sanitizeFormData } from 'src/utils/sanitizeFormData';
 
 type Payload = {
   datasource_id: number;
@@ -26,19 +26,12 @@ type Payload = {
   chart_id?: number;
 };
 
-const TEMPORARY_CONTROLS = ['url_params'];
-
-export const sanitizeFormData = (formData: JsonObject): JsonObject =>
-  omit(formData, TEMPORARY_CONTROLS);
-
 const assembleEndpoint = (key?: string, tabId?: string) => {
   let endpoint = 'api/v1/explore/form_data';
-  if (key) {
-    endpoint = endpoint.concat(`/${key}`);
-  }
-  if (tabId) {
-    endpoint = endpoint.concat(`?tab_id=${tabId}`);
-  }
+
+  if (key) endpoint = endpoint.concat(`/${key}`);
+  if (tabId) endpoint = endpoint.concat(`?tab_id=${tabId}`);
+
   return endpoint;
 };
 
@@ -47,15 +40,15 @@ const assemblePayload = (
   datasourceType: string,
   formData: JsonObject,
   chartId?: number,
-) => {
+): Payload => {
   const payload: Payload = {
     datasource_id: datasourceId,
     datasource_type: datasourceType,
     form_data: JSON.stringify(sanitizeFormData(formData)),
   };
-  if (chartId) {
-    payload.chart_id = chartId;
-  }
+
+  if (chartId) payload.chart_id = chartId;
+
   return payload;
 };
 
@@ -74,7 +67,7 @@ export const postFormData = (
       formData,
       chartId,
     ),
-  }).then(r => r.json.key);
+  }).then((r: JsonResponse) => r.json.key);
 
 export const putFormData = (
   datasourceId: number,
@@ -92,4 +85,4 @@ export const putFormData = (
       formData,
       chartId,
     ),
-  }).then(r => r.json.message);
+  }).then((r: JsonResponse) => r.json.message);
diff --git a/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts 
b/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
new file mode 100644
index 0000000000..b09adece6a
--- /dev/null
+++ b/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
@@ -0,0 +1,124 @@
+/**
+ * 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 { getClientErrorObject, t } from '@superset-ui/core';
+import { useEffect, useRef, useCallback, useState } from 'react';
+import { useHistory } from 'react-router-dom';
+
+type UseUnsavedChangesPromptProps = {
+  hasUnsavedChanges: boolean;
+  onSave: () => Promise<void> | void;
+  isSaveModalVisible?: boolean;
+  manualSaveOnUnsavedChanges?: boolean;
+};
+
+export const useUnsavedChangesPrompt = ({
+  hasUnsavedChanges,
+  onSave,
+  isSaveModalVisible = false,
+  manualSaveOnUnsavedChanges = false,
+}: UseUnsavedChangesPromptProps) => {
+  const history = useHistory();
+  const [showModal, setShowModal] = useState(false);
+
+  const confirmNavigationRef = useRef<(() => void) | null>(null);
+  const unblockRef = useRef<() => void>(() => {});
+  const manualSaveRef = useRef(false); // Track if save was user-initiated 
(not via navigation)
+
+  const handleConfirmNavigation = useCallback(() => {
+    confirmNavigationRef.current?.();
+  }, []);
+
+  const handleSaveAndCloseModal = useCallback(async () => {
+    try {
+      if (manualSaveOnUnsavedChanges) manualSaveRef.current = true;
+
+      await onSave();
+      setShowModal(false);
+    } catch (err) {
+      const clientError = await getClientErrorObject(err);
+      throw new Error(
+        clientError.message ||
+          clientError.error ||
+          t('Sorry, an error occurred'),
+        { cause: err },
+      );
+    }
+  }, [manualSaveOnUnsavedChanges, onSave]);
+
+  const triggerManualSave = useCallback(() => {
+    manualSaveRef.current = true;
+    onSave();
+  }, [onSave]);
+
+  const blockCallback = useCallback(
+    ({ pathname }: { pathname: string }) => {
+      if (manualSaveRef.current) {
+        manualSaveRef.current = false;
+        return undefined;
+      }
+
+      confirmNavigationRef.current = () => {
+        unblockRef.current?.();
+        history.push(pathname);
+      };
+
+      setShowModal(true);
+      return false;
+    },
+    [history],
+  );
+
+  useEffect(() => {
+    if (!hasUnsavedChanges) return undefined;
+
+    const unblock = history.block(blockCallback);
+    unblockRef.current = unblock;
+
+    return () => unblock();
+  }, [blockCallback, hasUnsavedChanges, history]);
+
+  useEffect(() => {
+    const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+      if (!hasUnsavedChanges) return;
+      event.preventDefault();
+
+      // Most browsers require a "returnValue" set to empty string
+      const evt = event as any;
+      evt.returnValue = '';
+    };
+
+    window.addEventListener('beforeunload', handleBeforeUnload);
+    return () => window.removeEventListener('beforeunload', 
handleBeforeUnload);
+  }, [hasUnsavedChanges]);
+
+  useEffect(() => {
+    if (!isSaveModalVisible && manualSaveRef.current) {
+      setShowModal(false);
+      manualSaveRef.current = false;
+    }
+  }, [isSaveModalVisible]);
+
+  return {
+    showModal,
+    setShowModal,
+    handleConfirmNavigation,
+    handleSaveAndCloseModal,
+    triggerManualSave,
+  };
+};
diff --git 
a/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx
 
b/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx
new file mode 100644
index 0000000000..bc42cdcfc9
--- /dev/null
+++ 
b/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx
@@ -0,0 +1,106 @@
+/**
+ * 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 { renderHook } from '@testing-library/react-hooks';
+import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
+import { Router } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
+import { act } from 'spec/helpers/testing-library';
+
+const history = createMemoryHistory({
+  initialEntries: ['/dashboard'],
+});
+
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+  <Router history={history}>{children}</Router>
+);
+
+describe('useUnsavedChangesPrompt', () => {
+  it('should not show modal initially', () => {
+    const { result } = renderHook(
+      () =>
+        useUnsavedChangesPrompt({
+          hasUnsavedChanges: true,
+          onSave: jest.fn(),
+        }),
+      { wrapper },
+    );
+
+    expect(result.current.showModal).toBe(false);
+  });
+
+  it('should block navigation and show modal if there are unsaved changes', () 
=> {
+    const { result } = renderHook(
+      () =>
+        useUnsavedChangesPrompt({
+          hasUnsavedChanges: true,
+          onSave: jest.fn(),
+        }),
+      { wrapper },
+    );
+
+    // Simulate blocked navigation
+    act(() => {
+      const unblock = history.block((tx: any) => tx);
+      unblock();
+      history.push('/another-page');
+    });
+
+    expect(result.current.showModal).toBe(true);
+  });
+
+  it('should trigger onSave and hide modal on handleSaveAndCloseModal', async 
() => {
+    const onSave = jest.fn().mockResolvedValue(undefined);
+
+    const { result } = renderHook(
+      () =>
+        useUnsavedChangesPrompt({
+          hasUnsavedChanges: true,
+          onSave,
+        }),
+      { wrapper },
+    );
+
+    await act(async () => {
+      await result.current.handleSaveAndCloseModal();
+    });
+
+    expect(onSave).toHaveBeenCalled();
+    expect(result.current.showModal).toBe(false);
+  });
+
+  it('should trigger manual save and not show modal again', async () => {
+    const onSave = jest.fn().mockResolvedValue(undefined);
+
+    const { result } = renderHook(
+      () =>
+        useUnsavedChangesPrompt({
+          hasUnsavedChanges: true,
+          onSave,
+        }),
+      { wrapper },
+    );
+
+    act(() => {
+      result.current.triggerManualSave();
+    });
+
+    expect(onSave).toHaveBeenCalled();
+    expect(result.current.showModal).toBe(false);
+  });
+});
diff --git a/superset-frontend/src/pages/Chart/Chart.test.tsx 
b/superset-frontend/src/pages/Chart/Chart.test.tsx
index 2fe7599211..6b0e9f820a 100644
--- a/superset-frontend/src/pages/Chart/Chart.test.tsx
+++ b/superset-frontend/src/pages/Chart/Chart.test.tsx
@@ -30,9 +30,12 @@ import { LocalStorageKeys } from 
'src/utils/localStorageHelpers';
 import getFormDataWithExtraFilters from 
'src/dashboard/util/charts/getFormDataWithExtraFilters';
 import { URL_PARAMS } from 'src/constants';
 import { JsonObject, VizType } from '@superset-ui/core';
-
+import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
 import ChartPage from '.';
 
+jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
+  useUnsavedChangesPrompt: jest.fn(),
+}));
 jest.mock('re-resizable', () => ({
   Resizable: () => <div data-test="mock-re-resizable" />,
 }));
@@ -48,6 +51,17 @@ jest.mock(
 jest.mock('src/dashboard/util/charts/getFormDataWithExtraFilters');
 
 describe('ChartPage', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+
+    (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({
+      showModal: false,
+      setShowModal: jest.fn(),
+      handleConfirmNavigation: jest.fn(),
+      handleSaveAndCloseModal: jest.fn(),
+    });
+  });
+
   afterEach(() => {
     fetchMock.reset();
   });
diff --git a/superset-frontend/src/components/AlteredSliceTag/types.ts 
b/superset-frontend/src/types/DiffType.ts
similarity index 77%
copy from superset-frontend/src/components/AlteredSliceTag/types.ts
copy to superset-frontend/src/types/DiffType.ts
index e1f6108608..e146df35be 100644
--- a/superset-frontend/src/components/AlteredSliceTag/types.ts
+++ b/superset-frontend/src/types/DiffType.ts
@@ -16,20 +16,6 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import type { QueryFormData } from '@superset-ui/core';
-
-export interface AlteredSliceTagProps {
-  origFormData: QueryFormData;
-  currentFormData: QueryFormData;
-}
-
-export interface ControlMap {
-  [key: string]: {
-    label?: string;
-    type?: string;
-  };
-}
-
 export type FilterItemType = {
   comparator?: string | string[];
   subject: string;
@@ -52,9 +38,3 @@ export type DiffType = {
   before: DiffItemType;
   after: DiffItemType;
 };
-
-export type RowType = {
-  before: string | number;
-  after: string | number;
-  control: string;
-};
diff --git 
a/superset-frontend/src/utils/getChartFormDiffs/getChartFormDiffs.test.ts 
b/superset-frontend/src/utils/getChartFormDiffs/getChartFormDiffs.test.ts
new file mode 100644
index 0000000000..6b7680ec1c
--- /dev/null
+++ b/superset-frontend/src/utils/getChartFormDiffs/getChartFormDiffs.test.ts
@@ -0,0 +1,115 @@
+/**
+ * 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 { JsonObject } from '@superset-ui/core';
+import { alterForComparison, getChartFormDiffs, isEqualish } from '.';
+
+jest.mock('../sanitizeFormData', () => ({
+  sanitizeFormData: (fd: JsonObject): JsonObject => ({
+    ...fd,
+    _sanitized: true,
+  }),
+}));
+
+describe('alterForComparison', () => {
+  it.each([
+    [null, null],
+    ['', null],
+    [[], null],
+    [{}, null],
+    [
+      [1, 2],
+      [1, 2],
+    ],
+    [{ a: 1 }, { a: 1 }],
+    ['foo', 'foo'],
+  ])('normalizes %p to %p', (input, expected) => {
+    expect(alterForComparison(input)).toEqual(expected);
+  });
+});
+
+describe('isEqualish', () => {
+  it('returns true for semantically equal values with different formats', () 
=> {
+    expect(isEqualish('', null)).toBe(true);
+    expect(isEqualish([], null)).toBe(true);
+    expect(isEqualish({}, null)).toBe(true);
+    expect(isEqualish([1], [1])).toBe(true);
+  });
+
+  it('returns false for clearly different values', () => {
+    expect(isEqualish([1], [2])).toBe(false);
+    expect(isEqualish({ a: 1 }, { a: 2 })).toBe(false);
+    expect(isEqualish('foo', 'bar')).toBe(false);
+  });
+});
+
+describe('getChartFormDiffs', () => {
+  it('returns diffs for changed values', () => {
+    const original = { metric: 'count', adhoc_filters: [] };
+    const current = { metric: 'sum__num', adhoc_filters: [] };
+
+    const diffs = getChartFormDiffs(original, current);
+
+    expect(diffs).toHaveProperty('metric');
+    expect(diffs.metric).toEqual({
+      before: 'count',
+      after: 'sum__num',
+    });
+  });
+
+  it('ignores noisy keys', () => {
+    const original = { where: 'a = 1', metric: 'count' };
+    const current = { where: 'a = 2', metric: 'sum__num' };
+
+    const diffs = getChartFormDiffs(original, current);
+
+    expect(diffs).not.toHaveProperty('where');
+    expect(diffs).toHaveProperty('metric');
+  });
+
+  it('does not include values that are equalish', () => {
+    const original = { filters: [], metric: 'count' };
+    const current = { filters: null, metric: 'count' };
+
+    const diffs = getChartFormDiffs(original, current);
+
+    expect(diffs).toEqual({});
+  });
+
+  it('handles missing keys in original or current gracefully', () => {
+    const original = { metric: 'count' };
+    const current = { metric: 'count', new_field: 'value' };
+
+    const diffs = getChartFormDiffs(original, current);
+
+    expect(diffs).toHaveProperty('new_field');
+    expect(diffs.new_field).toEqual({
+      before: undefined,
+      after: 'value',
+    });
+  });
+
+  it('ignores keys that are missing in current and not explicitly changed', () 
=> {
+    const original = { metric: 'count', removed_field: 'gone' };
+    const current = { metric: 'count' };
+
+    const diffs = getChartFormDiffs(original, current);
+
+    expect(diffs).not.toHaveProperty('removed_field');
+  });
+});
diff --git a/superset-frontend/src/utils/getChartFormDiffs/index.ts 
b/superset-frontend/src/utils/getChartFormDiffs/index.ts
new file mode 100644
index 0000000000..6c89b4fef3
--- /dev/null
+++ b/superset-frontend/src/utils/getChartFormDiffs/index.ts
@@ -0,0 +1,66 @@
+/**
+ * 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 { isEqual } from 'lodash';
+import { DiffType } from 'src/types/DiffType';
+import { JsonObject } from '@superset-ui/core';
+import { sanitizeFormData } from '../sanitizeFormData';
+
+export const noisyKeys = new Set(['filters', 'having', 'where']);
+
+export const alterForComparison = (value: unknown): unknown => {
+  if (value == null || value === '') return null;
+  if (Array.isArray(value) && value.length === 0) return null;
+  if (typeof value === 'object' && value && Object.keys(value).length === 0)
+    return null;
+
+  return value;
+};
+
+export const isEqualish = (a: unknown, b: unknown): boolean =>
+  isEqual(alterForComparison(a), alterForComparison(b));
+
+export const getChartFormDiffs = (
+  originalFormData: Record<string, unknown>,
+  currentFormData: Record<string, unknown>,
+): Record<string, DiffType> => {
+  const ofd: JsonObject = sanitizeFormData(originalFormData);
+  const cfd: JsonObject = sanitizeFormData(currentFormData);
+
+  const keys = new Set([...Object.keys(ofd), ...Object.keys(cfd)]);
+  const diffs: Record<string, DiffType> = {};
+
+  keys.forEach((key: string) => {
+    if (noisyKeys.has(key)) return;
+
+    const original = ofd[key];
+    const current = cfd[key];
+
+    const currentHasKey = Object.prototype.hasOwnProperty.call(cfd, key);
+    const originalHasKey = Object.prototype.hasOwnProperty.call(ofd, key);
+
+    const bothExplicit = currentHasKey && originalHasKey;
+
+    if (!bothExplicit && !currentHasKey) return;
+
+    if (!isEqualish(original, current))
+      diffs[key] = { before: original, after: current };
+  });
+
+  return diffs;
+};
diff --git a/superset-frontend/src/explore/exploreUtils/formData.test.ts 
b/superset-frontend/src/utils/sanitizeFormData/index.ts
similarity index 75%
copy from superset-frontend/src/explore/exploreUtils/formData.test.ts
copy to superset-frontend/src/utils/sanitizeFormData/index.ts
index f14455b51b..9c55cfc6cf 100644
--- a/superset-frontend/src/explore/exploreUtils/formData.test.ts
+++ b/superset-frontend/src/utils/sanitizeFormData/index.ts
@@ -16,13 +16,10 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { sanitizeFormData } from './formData';
+import { JsonObject } from '@superset-ui/core';
+import { omit } from 'lodash';
 
-test('sanitizeFormData removes temporary control values', () => {
-  expect(
-    sanitizeFormData({
-      url_params: { foo: 'bar' },
-      metrics: ['foo', 'bar'],
-    }),
-  ).toEqual({ metrics: ['foo', 'bar'] });
-});
+const TEMPORARY_CONTROLS: string[] = ['url_params'];
+
+export const sanitizeFormData = (formData: JsonObject): JsonObject =>
+  omit(formData, TEMPORARY_CONTROLS);
diff --git a/superset-frontend/src/explore/exploreUtils/formData.test.ts 
b/superset-frontend/src/utils/sanitizeFormData/sanitizeFormData.test.ts
similarity index 95%
copy from superset-frontend/src/explore/exploreUtils/formData.test.ts
copy to superset-frontend/src/utils/sanitizeFormData/sanitizeFormData.test.ts
index f14455b51b..98da5c8698 100644
--- a/superset-frontend/src/explore/exploreUtils/formData.test.ts
+++ b/superset-frontend/src/utils/sanitizeFormData/sanitizeFormData.test.ts
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { sanitizeFormData } from './formData';
+import { sanitizeFormData } from '.';
 
 test('sanitizeFormData removes temporary control values', () => {
   expect(


Reply via email to