Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,6 @@ jobs:
# queries: security-extended,security-and-quality

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:${{matrix.language}}"
15 changes: 15 additions & 0 deletions UPDATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ assists people when migrating to a new version.

## Next

### Archived dataset purge requires impact confirmation

`GET /api/v1/dataset/<uuid>/purge-impact` returns the charts and distinct
dashboards affected by permanently deleting an archived dataset, together with
an opaque `impact_token`. The dataset purge endpoint now requires that token in
the JSON body as `confirmed_impact_token`. API clients that call
`POST /api/v1/dataset/<uuid>/purge` must fetch and display the impact first;
requests with a missing or malformed token are rejected with 400.

The server rechecks the dependency identities immediately before mutation. If
they changed, purge performs no deletion and returns 409 with a refreshed impact
payload. Clients must display the new impact and obtain renewed confirmation
before retrying. Preview or recheck failures fail closed rather than treating
unknown impact as zero. Chart and dashboard purge endpoints are unchanged.

- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed should `pip uninstall cockroachdb && pip install sqlalchemy-cockroachdb` (or simply reinstall the extra) to restore CockroachDB connectivity.

Expand Down
19 changes: 19 additions & 0 deletions docs/docs/using-superset/recently-archived.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,24 @@ alert or report is removed, and the reason is shown. Charts that belong to
dashboards are removed from those dashboards as part of the deletion; the
dashboards themselves are left in place.

Before an archived dataset is deleted permanently, Superset checks which
charts still use it and which dashboards contain those charts. The confirmation
shows the total number of affected charts and dashboards, identifies the ones
you are allowed to access, and reports the remaining objects only as restricted
counts. Restricted names, identifiers, and links are not displayed. Archived
dependents are included because they can still be recovered after the dataset
is gone.

Deleting the dataset does not delete those charts or dashboards. They remain
in place without a usable dataset and may therefore be broken. If there are no
dependents, the confirmation explicitly reports zero affected charts and
dashboards.

The dependency check fails closed. While it is loading, or if its result is
unavailable, permanent deletion is disabled; cancel or retry the check. Superset
checks again when you submit. If dependencies changed while the confirmation
was open, the refreshed impact replaces the previous result and you must type
DELETE again before proceeding.

Objects are also deleted permanently on their own once they have been in the
archive longer than the retention window, without anyone acting.
6 changes: 3 additions & 3 deletions superset-embedded-sdk/package-lock.json

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

Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,61 @@ test('Calling "onConfirm" only after typing "delete" in the input', async () =>
// confirm input has been cleared
expect(screen.getByTestId('delete-modal-input')).toHaveValue('');
});

test('external disable keeps the destructive action unavailable after confirmation', async () => {
const onConfirm = jest.fn();
render(
<DeleteModal
title="Delete permanently?"
description="This cannot be undone."
onConfirm={onConfirm}
onHide={jest.fn()}
open
disablePrimaryButton
/>,
);

await userEvent.type(screen.getByTestId('delete-modal-input'), 'DELETE');

expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(onConfirm).not.toHaveBeenCalled();
});

test('loading disables the destructive action and exposes busy state', async () => {
render(
<DeleteModal
title="Delete permanently?"
description="Checking dependencies"
onConfirm={jest.fn()}
onHide={jest.fn()}
open
loading
/>,
);

await userEvent.type(screen.getByTestId('delete-modal-input'), 'DELETE');

expect(screen.getByTestId('modal-confirm-button')).toBeDisabled();
expect(screen.getByTestId('antd-modal')).toHaveAttribute('aria-busy', 'true');
});

test('confirmation reset key clears and re-arms type-to-confirm', async () => {
const props = {
title: 'Delete permanently?',
description: 'This cannot be undone.',
onConfirm: jest.fn(),
onHide: jest.fn(),
open: true,
};
const { rerender } = render(
<DeleteModal {...props} confirmationResetKey="initial" />,
);
await userEvent.type(screen.getByTestId('delete-modal-input'), 'DELETE');
expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled();

rerender(<DeleteModal {...props} confirmationResetKey="impact-changed" />);

expect(screen.getByTestId('delete-modal-input')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export function DeleteModal({
title,
name,
recoverable = false,
disablePrimaryButton = false,
loading = false,
confirmationResetKey,
}: DeleteModalProps) {
// Recoverable (archive) deletes drop the "type DELETE to confirm" step;
// a permanent delete keeps it.
Expand All @@ -54,6 +57,11 @@ export function DeleteModal({
}
}, [open]);

useEffect(() => {
setConfirmation('');
setDisableChange(true);
}, [confirmationResetKey]);

// Re-arm the gate alongside clearing the text: resetting only the string
// leaves disableChange=false behind, so a user who typed DELETE, cancelled,
// and reopened would face an enabled Delete button over an empty input.
Expand All @@ -76,21 +84,27 @@ export function DeleteModal({
};

const onPressEnter = () => {
if (!disableChange) {
if (!disableChange && !disablePrimaryButton && !loading) {
confirm();
}
};

return (
<Modal
disablePrimaryButton={showConfirmationInput ? disableChange : false}
disablePrimaryButton={
disablePrimaryButton ||
loading ||
(showConfirmationInput ? disableChange : false)
}
primaryButtonLoading={loading}
onHide={hide}
onHandledPrimaryAction={confirm}
primaryButtonName={recoverable ? t('Archive') : t('Delete')}
primaryButtonStyle={recoverable ? 'primary' : 'danger'}
show={open}
name={name}
title={title}
wrapProps={{ 'aria-busy': loading }}
centered
>
{description}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@ export interface DeleteModalProps {
* friction and uses a primary (non-danger) confirm button.
*/
recoverable?: boolean;
/** Disable confirmation independently of the typed-text gate. */
disablePrimaryButton?: boolean;
/** Show progress on the primary action and prevent duplicate submission. */
loading?: boolean;
/** Clear and re-arm the typed-text gate when the reviewed data changes. */
confirmationResetKey?: string | number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,97 @@ test('displays an error message when an exception is thrown while fetching', asy
expect(screen.getByText(error)).toBeInTheDocument();
});

test('clears a previous fetch error once a later fetch succeeds', async () => {
const error = 'Fetch error';
const loadOptions = jest.fn(async (search: string) => {
if (search === 'fail') {
throw new Error(error);
}
// Report more results than are loaded so every new search hits the server.
return { data: [{ label: search, value: search }], totalCount: 100 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await type('fail');
expect(await screen.findByText(error)).toBeInTheDocument();

await type('retry');
expect(await findSelectOption('retry')).toBeInTheDocument();
expect(screen.queryByText(error)).not.toBeInTheDocument();
});

test('clears a previous fetch error when the next page comes from cache', async () => {
const error = 'Fetch error';
const loadOptions = jest.fn(
async (search: string, page: number, pageSize: number) => {
if (search === 'fail') {
throw new Error(error);
}
return defaultProps.options(search, page, pageSize);
},
);
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await findSelectOption(OPTIONS[0].label);

await type('fail');
expect(await screen.findByText(error)).toBeInTheDocument();

// Clearing the input re-requests the first page, which is already cached
// and therefore never reaches the network.
await userEvent.clear(getSelect());
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
expect(screen.queryByText(error)).not.toBeInTheDocument();
});

test('ignores a late failure from a search the user has moved on from', async () => {
const error = 'Fetch error';
let rejectSlow: (reason: Error) => void = () => {};
const loadOptions = jest.fn(async (search: string) => {
if (search === 'slow') {
return new Promise<never>((_, reject) => {
rejectSlow = reject;
});
}
return { data: [{ label: search, value: search }], totalCount: 100 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await type('slow');
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('slow', 0, 10));

await type('fast');
expect(await findSelectOption('fast')).toBeInTheDocument();

rejectSlow(new Error(error));
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(3));
expect(screen.queryByText(error)).not.toBeInTheDocument();
expect(await findSelectOption('fast')).toBeInTheDocument();
});

test('still surfaces a base-fetch failure that lands mid-search', async () => {
const error = 'Fetch error';
let rejectBase: (reason: Error) => void = () => {};
const loadOptions = jest.fn(async (search: string) => {
if (search === '') {
// Defer the base page so it can fail after the user starts searching.
return new Promise<never>((_, reject) => {
rejectBase = reject;
});
}
return { data: [{ label: search, value: search }], totalCount: 100 };
});
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await type('abc');
expect(await findSelectOption('abc')).toBeInTheDocument();

// Base fetches keep the accumulator and allValuesLoaded up to date even
// mid-search, so their failures must surface too.
rejectBase(new Error(error));
expect(await screen.findByText(error)).toBeInTheDocument();
});

test('does not fire a new request for the same search input', async () => {
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ const AsyncSelect = forwardRef(
const fetchPage = useMemo(
() => (search: string, page: number) => {
setPage(page);
// A previous fetch may have left an error on screen. Clear it before
// any early return so a page served from cache, or from an already
// complete option set, is not shown next to a stale error.
setError('');
if (allValuesLoaded) {
setIsLoading(false);
return;
Expand Down Expand Up @@ -486,7 +490,19 @@ const AsyncSelect = forwardRef(
setTotalCount(totalCount);
}
})
.catch(internalOnError)
.catch((response: Response) => {
// Mirror the results guard above: a failure belonging to a search
// the user has since moved on from must not replace the outcome
// of the fetch that superseded it. Base fetches (search === '')
// are exempt exactly as their results are — they maintain the
// accumulator and allValuesLoaded, so their failures must stay
// visible even when they land mid-search. The consumer's onError
// is skipped along with the banner for superseded searches.
if (search && inputValueRef.current !== search) {
return undefined;
}
return internalOnError(response);
})
.finally(() => {
inFlightFetchesRef.current = Math.max(
0,
Expand Down
Loading
Loading