Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
97c8f54
fix(semantic): preserve archived dependencies and clarify delete dialogs
mikebridge Oct 20, 2026
119a57d
fix(semantic): clarify deletion guidance and guard archived dashboards
mikebridge Oct 20, 2026
da0f824
fix(semantic): keep deletion conflicts in confirmation dialogs
mikebridge Oct 21, 2026
2746774
fix(semantic): name the assets that block a semantic layer or view de…
mikebridge Oct 22, 2026
546d456
docs(semantic): regenerate OpenAPI for named archived dependents
mikebridge Oct 22, 2026
b642407
Merge apache master into sc-128723-semantic-delete-dialog
Oct 22, 2026
5bb9d45
fix(semantic): name archived blockers without widening live visibility
mikebridge Oct 22, 2026
2e689b1
fix(semantic): pluralise the hidden-blocker hint
mikebridge Oct 22, 2026
d5cdb99
fix(semantic): never name archived blockers to guest tokens
mikebridge Oct 22, 2026
cf1ce67
Merge sc-128723-semantic-delete-dialog into sc-128878-delete-blocker-…
mikebridge Oct 22, 2026
8a97757
fix(semantic): link blocking dashboards through the app-root route
mikebridge Oct 22, 2026
a3d85f6
fix(semantic-layer): link delete blockers only by numeric id
mikebridge Oct 22, 2026
539def1
Merge remote-tracking branch 'origin/master' into sc-128723-semantic-…
mikebridge Oct 23, 2026
5ba5518
Merge branch 'sc-128723-semantic-delete-dialog' into sc-128878-delete…
mikebridge Oct 23, 2026
bbaa846
chore(i18n): align semantic deletion template after stack merge
mikebridge Oct 23, 2026
b08ee3e
Merge apache master into semantic delete blocker names
mikebridge Oct 26, 2026
64f1e3e
fix(semantic-layer): require restore capability for archived blocker …
mikebridge Oct 26, 2026
d29e429
Merge apache master into delete blocker names
mikebridge Oct 27, 2026
4b20633
Merge apache master into delete blocker names
mikebridge Oct 27, 2026
384d844
Merge apache master into delete blocker names
mikebridge Oct 27, 2026
a3cd6b9
Merge preset/master into sc-128878-delete-blocker-names
mikebridge Oct 28, 2026
03cda6a
Merge apache master into delete blocker names
mikebridge Oct 30, 2026
c48298b
Merge apache master into delete blocker names
mikebridge Oct 30, 2026
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
11 changes: 7 additions & 4 deletions docs/docs/using-superset/recently-archived.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,15 @@ filter cleanup; the response warns that the dashboard's filters need review.
Archived charts and dashboards still block deletion of semantic views and layers
they depend on. The refusal includes the number of archived dependents. Use
**Recently Archived** to restore and repoint them, or permanently delete them,
before deleting the semantic source. If you cannot access those assets, ask an
admin for help.
before deleting the semantic layer or view. If you cannot access those assets,
ask an admin for help.

When deleting a semantic layer or view, a dependency refusal stays in the
confirmation dialog with the total and archived counts and a link to Recently
Archived. The typed confirmation is preserved so you can retry after removing
confirmation dialog with the total and archived counts, the names of up to 20
blocking assets you can see, and a link to Recently Archived. Archived blockers
are named only to users who could restore them from Recently Archived (admins
and the asset's editors with write permission on its resource type); the dialog counts the others and suggests asking an
admin. The typed confirmation is preserved so you can retry after removing
or repointing the dependents. Dependencies are checked when you confirm deletion,
not before you type the confirmation.

Expand Down
24 changes: 18 additions & 6 deletions docs/static/resources/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -35269,12 +35269,15 @@
"schema": {
"properties": {
"archived_count": {
"description": "Archived dependents are included in inaccessible_count; these counts overlap and must not be added together.",
"description": "Archived dependents are named only to users who could restore them from Recently Archived; the others are included in inaccessible_count. The counts overlap and must not be added together.",
"type": "integer"
},
"dependents": {
"items": {
"properties": {
"archived": {
"type": "boolean"
},
"id": {
"type": "integer"
},
Expand All @@ -35294,7 +35297,8 @@
"required": [
"type",
"id",
"name"
"name",
"archived"
],
"type": "object"
},
Expand Down Expand Up @@ -35659,12 +35663,15 @@
"schema": {
"properties": {
"archived_count": {
"description": "Archived dependents are included in inaccessible_count; these counts overlap and must not be added together.",
"description": "Archived dependents are named only to users who could restore them from Recently Archived; the others are included in inaccessible_count. The counts overlap and must not be added together.",
"type": "integer"
},
"dependents": {
"items": {
"properties": {
"archived": {
"type": "boolean"
},
"id": {
"type": "integer"
},
Expand All @@ -35684,7 +35691,8 @@
"required": [
"type",
"id",
"name"
"name",
"archived"
],
"type": "object"
},
Expand Down Expand Up @@ -35827,12 +35835,15 @@
"schema": {
"properties": {
"archived_count": {
"description": "Archived dependents are included in inaccessible_count; these counts overlap and must not be added together.",
"description": "Archived dependents are named only to users who could restore them from Recently Archived; the others are included in inaccessible_count. The counts overlap and must not be added together.",
"type": "integer"
},
"dependents": {
"items": {
"properties": {
"archived": {
"type": "boolean"
},
"id": {
"type": "integer"
},
Expand All @@ -35852,7 +35863,8 @@
"required": [
"type",
"id",
"name"
"name",
"archived"
],
"type": "object"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* 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 } from 'spec/helpers/testing-library';
import SemanticDeleteBlockers, {
SemanticDeleteError,
} from './SemanticDeleteBlockers';

test('markup in the refusal is shown as text, not rendered as HTML', () => {
const error = {
error: '<img src=x onerror=alert(1)>Blocked',
dependents: [
{ type: 'chart', id: 7, name: '<b>Revenue</b>', archived: false },
{
type: 'dashboard',
id: '/evil.example' as unknown as number,
name: 'Odd board',
archived: false,
},
],
} satisfies SemanticDeleteError;
const { container } = render(<SemanticDeleteBlockers error={error} />);
expect(
screen.getByText('<img src=x onerror=alert(1)>Blocked'),
).toBeInTheDocument();
expect(screen.getByRole('link', { name: '<b>Revenue</b>' })).toHaveAttribute(
'href',
'/explore/?slice_id=7',
);
expect(container.querySelector('img, b')).toBeNull();
// An id that is not a whole number never becomes a link.
expect(screen.getByText(/Odd board/)).toBeInTheDocument();
expect(
screen.queryByRole('link', { name: 'Odd board' }),
).not.toBeInTheDocument();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* 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, tn } from '@apache-superset/core/translation';
import { Alert } from '@apache-superset/core/components';
import { ensureAppRoot } from 'src/utils/navigationUtils';

interface Dependent {
type: string;
id: number;
name: string;
archived?: boolean;
}

export interface SemanticDeleteError {
error: string;
total?: number;
archived_count?: number;
inaccessible_count?: number;
dependents?: Dependent[];
}

const typeLabel = (type: string): string => {
const labels: Record<string, string> = {
chart: t('chart'),
dashboard: t('dashboard'),
alert: t('alert'),
report: t('report'),
};
return labels[type] ?? type;
};

const LIVE_LINKS: Record<string, (id: number) => string> = {
chart: id => `/explore/?slice_id=${id}`,
dashboard: id => `/dashboard/${id}/`,
};

/** The refusal shown inside a semantic layer or view delete dialog. */
export default function SemanticDeleteBlockers({
error,
}: {
error: SemanticDeleteError;
}) {
const dependents = error.dependents ?? [];
const hidden = error.inaccessible_count ?? 0;
const unlisted = (error.total ?? 0) - hidden - dependents.length;
const counted =
typeof error.total === 'number' && typeof error.archived_count === 'number';
const summary = counted
? t(
'Deletion is blocked by %s dependent assets (%s archived). Remove or repoint them before deleting.',
error.total,
error.archived_count,
)
: error.error;
// Archived assets have no page to open; Recently Archived lists them.
// The id comes from the error body, so only a whole number becomes a link.
const href = (item: Dependent): string | undefined => {
const id = Number(item.id);
return item.archived || !Number.isSafeInteger(id)
? undefined
: LIVE_LINKS[item.type]?.(id);
};
return (
<Alert
type="error"
showIcon
message={summary}
description={
<>
{dependents.length > 0 && (
<ul>
{dependents.map(item => {
const link = href(item);
const name = String(item.name);
return (
<li key={`${item.type}-${item.id}`}>
{link ? <a href={ensureAppRoot(link)}>{name}</a> : name} (
{item.archived
? t('archived %s', typeLabel(item.type))
: typeLabel(item.type)}
)
</li>
);
})}
</ul>
)}
{unlisted > 0 && <p>{t('and %s more', unlisted)}</p>}
{hidden > 0 && (
<p>
{tn(
"%s dependent asset is one you can't see. Ask an admin for help.",
"%s dependent assets are ones you can't see. Ask an admin for help.",
hidden,
hidden,
)}
</p>
)}
<a href={ensureAppRoot('/archived/')}>{t('Recently Archived')}</a>
</>
}
/>
);
}
33 changes: 7 additions & 26 deletions superset-frontend/src/pages/DatabaseList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Alert } from '@apache-superset/core/components';
import { t, tn } from '@apache-superset/core/translation';
import {
getExtensionsRegistry,
Expand Down Expand Up @@ -75,6 +74,9 @@ import { DatabaseObject } from 'src/features/databases/types';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
import SemanticDeleteBlockers, {
SemanticDeleteError,
} from 'src/features/semanticLayers/SemanticDeleteBlockers';
import type User from 'src/types/User';
import {
databaseLabel,
Expand Down Expand Up @@ -433,11 +435,8 @@ function DatabaseList({
const [slCurrentlyEditing, setSlCurrentlyEditing] = useState<string | null>(
null,
);
const [slDeleteError, setSLDeleteError] = useState<{
error: string;
total?: number;
archived_count?: number;
} | null>(null);
const [slDeleteError, setSLDeleteError] =
useState<SemanticDeleteError | null>(null);
const [slDeleting, setSLDeleting] = useState(false);
const slDeleteRequest = useRef(0);
const [slDeletePreview, setSlDeletePreview] =
Expand Down Expand Up @@ -1250,25 +1249,7 @@ function DatabaseList({
</p>
<SemanticLayerCascadeWarning preview={slDeletePreview} />
{slDeleteError && (
<Alert
type="error"
showIcon
message={
<>
{typeof slDeleteError.total === 'number' &&
typeof slDeleteError.archived_count === 'number'
? t(
'Deletion is blocked by %s dependent assets (%s archived). Remove or repoint them before deleting.',
slDeleteError.total,
slDeleteError.archived_count,
)
: slDeleteError.error}{' '}
<a href={ensureAppRoot('/archived/')}>
{t('Recently Archived')}
</a>
</>
}
/>
<SemanticDeleteBlockers error={slDeleteError} />
)}
</>
}
Expand Down Expand Up @@ -1308,7 +1289,7 @@ function DatabaseList({
databaseCurrentlyDeleting.datasets.count,
)}{' '}
{t(
'Delete or move active datasets. To remove archived datasets, open Recently archived and select Delete permanently.',
'Delete or move active datasets. To remove archived datasets, open Recently Archived and select Delete permanently.',
)}
</p>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,57 @@ test('single semantic view delete displays the counted dependency conflict', asy
).toHaveValue('');
});

test('single semantic view delete names its blockers, including archived ones', async () => {
const view = {
...mockDatasets[0],
id: 99,
table_name: 'orders_semantic',
kind: 'semantic_view',
};
mockDatasetListEndpoints({ result: [view], count: 1 });
fetchMock.delete('glob:*/api/v1/semantic_view/99', {
status: 409,
body: {
message: 'blocked',
total: 6,
archived_count: 1,
inaccessible_count: 2,
dependents: [
{ type: 'chart', id: 7, name: 'Revenue chart', archived: false },
{ type: 'dashboard', id: 8, name: 'Old board', archived: true },
{ type: 'dashboard', id: 9, name: 'Live board', archived: false },
],
},
});
renderDatasetList(mockAdminUser, {
addDangerToast: jest.fn(),
addSuccessToast: jest.fn(),
});
await screen.findByText(view.table_name);
await userEvent.click(screen.getByTestId('dataset-row-delete'));
const modal = await screen.findByRole('dialog');
await userEvent.type(
within(modal).getByTestId('delete-modal-input'),
'DELETE',
);
await userEvent.click(within(modal).getByRole('button', { name: 'Delete' }));
const alert = await within(modal).findByRole('alert');
expect(
within(alert).getByRole('link', { name: 'Revenue chart' }),
).toHaveAttribute('href', '/explore/?slice_id=7');
expect(alert).toHaveTextContent('Old board (archived dashboard)');
expect(
within(alert).getByRole('link', { name: 'Live board' }),
).toHaveAttribute('href', '/dashboard/9/');
expect(
within(alert).queryByRole('link', { name: 'Old board' }),
).not.toBeInTheDocument();
expect(alert).toHaveTextContent('and 1 more');
expect(alert).toHaveTextContent(
"2 dependent assets are ones you can't see. Ask an admin for help.",
);
});

test('mixed bulk delete names refused semantic views and their conflict reason', async () => {
const dataset = mockDatasets[0];
const semanticView = {
Expand Down
Loading
Loading