mirror of
https://github.com/apache/superset.git
synced 2026-08-03 20:42:30 +00:00
Compare commits
5 Commits
sentry-12e
...
fix/unsave
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cafdd491bc | ||
|
|
481d78db64 | ||
|
|
fc6e4321f3 | ||
|
|
7f102fa853 | ||
|
|
a0851f38b6 |
@@ -24,6 +24,15 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### `UnsavedChangesModal` no longer accepts a `zIndex` prop
|
||||
|
||||
`@superset-ui/core`'s `UnsavedChangesModal` dropped its `zIndex` prop (and the
|
||||
hardcoded default it fed) in favor of letting Ant Design's own stacking
|
||||
handle placement. Callers passing `zIndex` to override the modal's layering
|
||||
will now get a TypeScript error and must remove the prop; keeping a manual
|
||||
override was exactly the footgun this change removes (see #42510). No
|
||||
callers in the Superset frontend codebase itself passed this prop.
|
||||
|
||||
### Principal listing APIs now honour related-field filters
|
||||
|
||||
Two authorization-related listing behaviors changed for API clients. Neither
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../Button';
|
||||
import { Modal } from './Modal';
|
||||
import type { ModalProps, ModalFuncProps } from './types';
|
||||
@@ -179,3 +180,74 @@ ModalFunctions.args = {
|
||||
maskClosable: true,
|
||||
mask: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Two top-level Modals that are React siblings, not nested inside one
|
||||
* another (e.g. a "View query" modal and a confirmation dialog it can
|
||||
* trigger, like `UnsavedChangesModal`). Ant Design only assigns an
|
||||
* automatically-incremented z-index when a Modal is nested inside another
|
||||
* *currently open* Modal's React tree, so two siblings always fall back to
|
||||
* the same static z-index and are tie-broken by DOM order: whichever
|
||||
* `.ant-modal-wrap` was inserted later paints on top.
|
||||
*
|
||||
* With `destroyOnHidden={false}` (Ant Design's default), a Modal's wrap
|
||||
* node is created once, lazily, on first open, and is never removed or
|
||||
* recreated afterward. So the modal that happens to have been opened
|
||||
* *first ever*, not most recently, keeps winning the DOM-order tiebreak
|
||||
* even after being closed and reopened. Toggle "Reproduce stale DOM order"
|
||||
* off to see the fix: with `destroyOnHidden`, every open recreates the wrap
|
||||
* node at the end of the document, so DOM order (and stacking) always
|
||||
* matches true open-recency and no manual z-index is ever needed.
|
||||
*
|
||||
* To see the bug: click "Open A", close it, then "Open B", then "Open A"
|
||||
* again -- with the toggle on, A renders behind B despite being the modal
|
||||
* that was opened most recently.
|
||||
*/
|
||||
export const SiblingModalStacking = ({
|
||||
reproduceStaleDomOrder,
|
||||
}: {
|
||||
reproduceStaleDomOrder: boolean;
|
||||
}) => {
|
||||
const [showA, setShowA] = useState(false);
|
||||
const [showB, setShowB] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={() => setShowA(true)} buttonStyle="secondary">
|
||||
Open A
|
||||
</Button>
|
||||
<Button onClick={() => setShowB(true)} buttonStyle="secondary">
|
||||
Open B
|
||||
</Button>
|
||||
<Modal
|
||||
name="modal-a"
|
||||
title="Modal A"
|
||||
show={showA}
|
||||
onHide={() => setShowA(false)}
|
||||
destroyOnHidden={!reproduceStaleDomOrder}
|
||||
>
|
||||
Modal A content
|
||||
</Modal>
|
||||
<Modal
|
||||
name="modal-b"
|
||||
title="Modal B"
|
||||
show={showB}
|
||||
onHide={() => setShowB(false)}
|
||||
destroyOnHidden={!reproduceStaleDomOrder}
|
||||
>
|
||||
Modal B content
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SiblingModalStacking.args = {
|
||||
reproduceStaleDomOrder: true,
|
||||
};
|
||||
|
||||
SiblingModalStacking.argTypes = {
|
||||
reproduceStaleDomOrder: {
|
||||
control: 'boolean',
|
||||
description:
|
||||
'On: Ant Design default behavior, a modal opened once keeps its DOM position forever (the bug from #42510). Off: destroyOnHidden, DOM order always matches true open-recency (the fix).',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,7 +16,15 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@superset-ui/core/spec';
|
||||
import { Modal } from '@superset-ui/core/components';
|
||||
import { UnsavedChangesModal } from '.';
|
||||
|
||||
test('should render nothing if showModal is false', () => {
|
||||
@@ -94,3 +102,128 @@ test('should only call handleSave when clicking the Save button', async () => {
|
||||
expect(mockOnHide).not.toHaveBeenCalled();
|
||||
expect(mockOnConfirmNavigation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Regression coverage for the underlying bug (#42510): this modal could
|
||||
// render BEHIND another already-open modal (e.g. a draggable "View query"
|
||||
// modal). Ant Design only assigns a Modal a higher z-index automatically
|
||||
// when it's nested inside another *currently open* Modal's React tree --
|
||||
// two top-level siblings (this modal's Modal and whatever it's interrupting
|
||||
// are always siblings, never nested in each other) both fall back to the
|
||||
// same static z-index, tie-broken by DOM order: whichever `.ant-modal-wrap`
|
||||
// comes later in the document paints on top. So the invariant this modal
|
||||
// actually needs to hold isn't "higher z-index than the other modal" (both
|
||||
// are legitimately unset/tied by design) -- it's "always ends up later in
|
||||
// the DOM than whatever it's interrupting, no matter what already happened
|
||||
// on the page." A modal's wrap node is created once, lazily, on first open,
|
||||
// and normally stays in that DOM position forever; `destroyOnHidden` is
|
||||
// what makes every open recreate it fresh at the end of the document.
|
||||
function dialogWrap(titleText: string) {
|
||||
const dialogs = screen.queryAllByRole('dialog');
|
||||
// rc-util's `useId` hook always returns the same mocked id ("test-id") in
|
||||
// test environments, so with two dialogs open at once their
|
||||
// `aria-labelledby` ids collide and `getByRole('dialog', { name })` can't
|
||||
// tell them apart. Find each by its title text instead.
|
||||
const dialog = dialogs.find(d => within(d).queryByText(titleText));
|
||||
return dialog?.closest<HTMLElement>('.ant-modal-wrap') ?? null;
|
||||
}
|
||||
|
||||
test('renders above an already-open modal without a hardcoded z-index', async () => {
|
||||
render(
|
||||
<>
|
||||
<Modal show title="Other open modal" onHide={() => {}}>
|
||||
<div>Other modal content</div>
|
||||
</Modal>
|
||||
<UnsavedChangesModal
|
||||
showModal
|
||||
onHide={() => {}}
|
||||
handleSave={() => {}}
|
||||
onConfirmNavigation={() => {}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
const otherWrap = await waitFor(() => {
|
||||
const wrap = dialogWrap('Other open modal');
|
||||
expect(wrap).not.toBeNull();
|
||||
return wrap as HTMLElement;
|
||||
});
|
||||
const unsavedChangesWrap = await waitFor(() => {
|
||||
const wrap = dialogWrap('Unsaved Changes');
|
||||
expect(wrap).not.toBeNull();
|
||||
return wrap as HTMLElement;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
expect(
|
||||
otherWrap.compareDocumentPosition(unsavedChangesWrap) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('still renders on top after being opened, closed, and reopened once the other modal is already open', async () => {
|
||||
function Harness() {
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
const [showUnsaved, setShowUnsaved] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setShowOther(true)}>
|
||||
open other
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowUnsaved(true)}>
|
||||
open unsaved
|
||||
</button>
|
||||
<Modal
|
||||
show={showOther}
|
||||
title="Other open modal"
|
||||
onHide={() => setShowOther(false)}
|
||||
>
|
||||
<div>Other modal content</div>
|
||||
</Modal>
|
||||
<UnsavedChangesModal
|
||||
showModal={showUnsaved}
|
||||
onHide={() => setShowUnsaved(false)}
|
||||
handleSave={() => {}}
|
||||
// Mirrors real callers: confirming navigation is what dismisses
|
||||
// this modal, not `onHide` directly (see the Discard-button test
|
||||
// above -- clicking Discard never calls `onHide` on its own).
|
||||
onConfirmNavigation={() => setShowUnsaved(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
// Open this modal once -- e.g. some other in-app action tripped it --
|
||||
// before the modal it's supposed to interrupt has ever been opened. Its
|
||||
// wrap node gets created now, first in the document.
|
||||
userEvent.click(screen.getByText('open unsaved'));
|
||||
await waitFor(() => expect(dialogWrap('Unsaved Changes')).not.toBeNull());
|
||||
userEvent.click(await screen.findByRole('button', { name: /discard/i }));
|
||||
await waitFor(() => expect(dialogWrap('Unsaved Changes')).toBeNull());
|
||||
|
||||
// Now open the modal it's meant to interrupt for the first time.
|
||||
userEvent.click(screen.getByText('open other'));
|
||||
const otherWrap = await waitFor(() => {
|
||||
const wrap = dialogWrap('Other open modal');
|
||||
expect(wrap).not.toBeNull();
|
||||
return wrap as HTMLElement;
|
||||
});
|
||||
|
||||
// Reopen this modal -- the real scenario the bug report describes. If its
|
||||
// wrap node were still the one created on the first open above, it would
|
||||
// be stuck earlier in the document than `otherWrap` and render behind it
|
||||
// again.
|
||||
userEvent.click(screen.getByText('open unsaved'));
|
||||
const unsavedChangesWrap = await waitFor(() => {
|
||||
const wrap = dialogWrap('Unsaved Changes');
|
||||
expect(wrap).not.toBeNull();
|
||||
return wrap as HTMLElement;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
expect(
|
||||
otherWrap.compareDocumentPosition(unsavedChangesWrap) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -20,10 +20,6 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { Icons, Modal, Typography, Button } from '@superset-ui/core/components';
|
||||
import type { FC, ReactElement } from 'react';
|
||||
|
||||
// Ant Design's default modal zIndex is 1000. Using a higher value ensures
|
||||
// this dialog always renders above other open modals (e.g. a draggable View SQL modal).
|
||||
const UNSAVED_CHANGES_MODAL_Z_INDEX = 1300;
|
||||
|
||||
export type UnsavedChangesModalProps = {
|
||||
showModal: boolean;
|
||||
onHide: () => void;
|
||||
@@ -31,7 +27,6 @@ export type UnsavedChangesModalProps = {
|
||||
onConfirmNavigation: () => void;
|
||||
title?: string;
|
||||
body?: string;
|
||||
zIndex?: number;
|
||||
};
|
||||
|
||||
export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
|
||||
@@ -41,7 +36,6 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
|
||||
onConfirmNavigation,
|
||||
title = 'Unsaved Changes',
|
||||
body = "If you don't save, changes will be lost.",
|
||||
zIndex = UNSAVED_CHANGES_MODAL_Z_INDEX,
|
||||
}: UnsavedChangesModalProps): ReactElement => (
|
||||
<Modal
|
||||
centered
|
||||
@@ -49,7 +43,19 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
|
||||
onHide={onHide}
|
||||
show={showModal}
|
||||
width="444px"
|
||||
zIndex={zIndex}
|
||||
// This modal always interrupts something already on screen (a draggable
|
||||
// "View query" modal, an in-progress form, etc). Ant Design only assigns
|
||||
// a higher z-index automatically when a Modal is nested inside another
|
||||
// open Modal's React tree; two top-level siblings both fall back to the
|
||||
// same static z-index and are tie-broken by DOM order instead. Without
|
||||
// destroyOnHidden, a Modal's portal node is created once (lazily, on
|
||||
// first open) and then left in place forever, so if this dialog is ever
|
||||
// opened once before whatever it's interrupting is opened, a later
|
||||
// reopen would go right back to that stale, now-too-early DOM position
|
||||
// and render behind it again. destroyOnHidden tears the portal down on
|
||||
// every close, so every open recreates it at the end of the DOM and it
|
||||
// reliably paints on top -- no z-index, hardcoded or otherwise, needed.
|
||||
destroyOnHidden
|
||||
title={
|
||||
<>
|
||||
<Icons.WarningOutlined iconSize="m" style={{ marginRight: 8 }} />
|
||||
|
||||
Reference in New Issue
Block a user