mirror of
https://github.com/apache/superset.git
synced 2026-08-25 01:21:18 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f83fb7c0e0 | ||
|
|
a9d54a0037 |
@@ -26,7 +26,8 @@ page and its menu entry are hidden, and deletes are permanent as before.
|
||||
## Finding archived objects
|
||||
|
||||
Open **Recently Archived** and pick a type — **Chart**, **Dashboard**, or
|
||||
**Dataset** — from the Type selector. The view shows one type at a time; each
|
||||
**Dataset** (shown as **Datasource** when semantic layers are enabled) — from
|
||||
the Type selector. The view shows one type at a time; each
|
||||
type is read from its own list endpoint, so the same row-level access rules that
|
||||
govern the normal lists apply here.
|
||||
|
||||
|
||||
@@ -18,9 +18,15 @@
|
||||
*/
|
||||
import { createMemoryHistory, type Update } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
|
||||
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import type Chart from 'src/types/Chart';
|
||||
import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import ChartCard from './ChartCard';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
@@ -37,7 +43,18 @@ const mockChart = {
|
||||
thumbnail_url: '/thumbnail.png',
|
||||
} as Chart;
|
||||
|
||||
const renderCard = (history: ReturnType<typeof createMemoryHistory>) =>
|
||||
// Admin qualifies as editor, so the card's delete entry is enabled.
|
||||
const adminUser = {
|
||||
userId: 1,
|
||||
username: 'admin',
|
||||
roles: { Admin: [] },
|
||||
permissions: {},
|
||||
} as unknown as UserWithPermissionsAndRoles;
|
||||
|
||||
const renderCard = (
|
||||
history: ReturnType<typeof createMemoryHistory>,
|
||||
props: Partial<React.ComponentProps<typeof ChartCard>> = {},
|
||||
) =>
|
||||
render(
|
||||
<Router history={history}>
|
||||
<ChartCard
|
||||
@@ -52,6 +69,7 @@ const renderCard = (history: ReturnType<typeof createMemoryHistory>) =>
|
||||
favoriteStatus={false}
|
||||
showThumbnails
|
||||
handleBulkChartExport={jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
@@ -106,3 +124,44 @@ test('clicking the card outside the thumbnail navigates to the chart', () => {
|
||||
|
||||
expect(navigations).toEqual(['PUSH /explore/?slice_id=1']);
|
||||
});
|
||||
|
||||
test('with soft delete on, the card delete flow shows the archive dialog', async () => {
|
||||
(isFeatureEnabled as jest.Mock).mockImplementation(
|
||||
flag => flag === FeatureFlag.SoftDelete,
|
||||
);
|
||||
renderCard(createMemoryHistory(), { user: adminUser });
|
||||
|
||||
fireEvent.click(screen.getByTestId('chart-card-menu'));
|
||||
fireEvent.click(await screen.findByText('Archive'));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText('Archive Sample Chart?')).toBeInTheDocument();
|
||||
// The body comes from the shared soft-delete copy module; its exact
|
||||
// wording evolves there (location hint, retention clause), so pin the
|
||||
// stable prefix rather than a full sentence.
|
||||
expect(
|
||||
within(dialog).getByText(/This chart will be moved to Recently Archived/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: 'Archive' }),
|
||||
).toBeInTheDocument();
|
||||
// Recoverable deletes drop the type-DELETE friction.
|
||||
expect(
|
||||
within(dialog).queryByTestId('delete-modal-input'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('with soft delete off, the card delete dialog is the permanent-delete one', async () => {
|
||||
(isFeatureEnabled as jest.Mock).mockReturnValue(false);
|
||||
renderCard(createMemoryHistory(), { user: adminUser });
|
||||
|
||||
fireEvent.click(screen.getByTestId('chart-card-menu'));
|
||||
fireEvent.click(await screen.findByText('Delete'));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText('Please confirm')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByText(/Are you sure you want to delete/),
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).getByTestId('delete-modal-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -38,6 +38,10 @@ import {
|
||||
isNavigationHandledByLink,
|
||||
} from 'src/views/CRUD/utils';
|
||||
import { assetUrl } from 'src/utils/assetUrl';
|
||||
import {
|
||||
archiveConfirmDescription,
|
||||
deleteActionLabel,
|
||||
} from 'src/utils/softDeleteCopy';
|
||||
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
|
||||
import { TableTab } from 'src/views/CRUD/types';
|
||||
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
@@ -159,15 +163,29 @@ export default function ChartCard({
|
||||
}
|
||||
|
||||
if (canDelete) {
|
||||
// With soft delete on, deleting archives the chart (recoverable), so the
|
||||
// confirmation drops the type-DELETE friction and uses the shared archive
|
||||
// copy -- matching the list view's dialog for the same action.
|
||||
const softDelete = isFeatureEnabled(FeatureFlag.SoftDelete);
|
||||
menuItems.push({
|
||||
key: 'delete',
|
||||
label: (
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
recoverable={softDelete}
|
||||
title={
|
||||
softDelete
|
||||
? t('Archive %(name)s?', { name: chart.slice_name })
|
||||
: t('Please confirm')
|
||||
}
|
||||
description={
|
||||
<>
|
||||
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>?
|
||||
</>
|
||||
softDelete ? (
|
||||
<p>{archiveConfirmDescription(t('chart'))}</p>
|
||||
) : (
|
||||
<>
|
||||
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>
|
||||
?
|
||||
</>
|
||||
)
|
||||
}
|
||||
onConfirm={() =>
|
||||
handleChartDelete(
|
||||
@@ -204,7 +222,7 @@ export default function ChartCard({
|
||||
vertical-align: text-top;
|
||||
`}
|
||||
/>{' '}
|
||||
{t('Delete')}
|
||||
{deleteActionLabel()}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
fireEvent,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
selectOption,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
|
||||
@@ -88,6 +90,15 @@ const mockCharts = [
|
||||
// list so `_info` requests resolve to it rather than the broader list glob.
|
||||
// withToasts injects the toast callbacks as props; the harness renders no
|
||||
// toast container, so the spy is the only way to pin what the user is told.
|
||||
// The type label for the dataset concept is flag-aware (SEMANTIC_LAYERS →
|
||||
// "Datasource"); mock the flag reader so tests can exercise both states. The
|
||||
// default (false for every flag) matches the real test environment, where no
|
||||
// bootstrap flags are set.
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
const mockAddDangerToast = jest.fn();
|
||||
jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||
__esModule: true,
|
||||
@@ -144,6 +155,13 @@ beforeEach(() => {
|
||||
mockAddDangerToast.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// The flag mock is shared module state; restore the environment default so a
|
||||
// flag-flipping test that dies mid-body (e.g. by Jest timeout) cannot leak
|
||||
// SEMANTIC_LAYERS into whichever test runs next.
|
||||
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
|
||||
});
|
||||
|
||||
test('renders archived rows with Name and Type columns', async () => {
|
||||
mockRoutes();
|
||||
renderArchivedList();
|
||||
@@ -573,3 +591,57 @@ test('a viewer who can read none of the types gets an empty state, not three 403
|
||||
// No list fetch was ever issued.
|
||||
expect(fetchMock.callHistory.calls(/chart\/\?q/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('labels the dataset type "Datasource" when semantic layers is enabled', async () => {
|
||||
(isFeatureEnabled as jest.Mock).mockImplementation(
|
||||
(flag: FeatureFlag) => flag === FeatureFlag.SemanticLayers,
|
||||
);
|
||||
mockRoutes();
|
||||
renderArchivedList();
|
||||
await screen.findByText('Deleted Chart One');
|
||||
|
||||
userEvent.click(screen.getByRole('combobox', { name: 'Type' }));
|
||||
expect(
|
||||
await screen.findByRole('option', { name: 'Datasource' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Dataset' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Selecting the renamed option still drives the dataset resource —
|
||||
// the underlying type value is flag-independent.
|
||||
await selectOption('Datasource', 'Type');
|
||||
await screen.findByText('deleted_table_one');
|
||||
expect(
|
||||
fetchMock.callHistory.calls(datasetListEndpoint).length,
|
||||
).toBeGreaterThan(0);
|
||||
// Pin the Type COLUMN cell, not just the Select's own rendered value.
|
||||
const datasetRow = screen.getByText('deleted_table_one').closest('tr');
|
||||
expect(
|
||||
within(datasetRow as HTMLElement).getByText('Datasource'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('labels the dataset type "Dataset" when semantic layers is disabled', async () => {
|
||||
mockRoutes();
|
||||
renderArchivedList();
|
||||
await screen.findByText('Deleted Chart One');
|
||||
|
||||
userEvent.click(screen.getByRole('combobox', { name: 'Type' }));
|
||||
expect(
|
||||
await screen.findByRole('option', { name: 'Dataset' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Datasource' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await selectOption('Dataset', 'Type');
|
||||
await screen.findByText('deleted_table_one');
|
||||
expect(
|
||||
fetchMock.callHistory.calls(datasetListEndpoint).length,
|
||||
).toBeGreaterThan(0);
|
||||
const datasetRow = screen.getByText('deleted_table_one').closest('tr');
|
||||
expect(
|
||||
within(datasetRow as HTMLElement).getByText('Dataset'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type ListViewFilters,
|
||||
} from 'src/components';
|
||||
import SubMenu from 'src/features/home/SubMenu';
|
||||
import { datasetLabel } from 'src/features/semanticLayers/label';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { recoveredToast } from 'src/utils/softDeleteCopy';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
@@ -82,10 +83,12 @@ const EmptyStateRow = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
const TYPE_LABELS: Record<ArchivedType, string> = {
|
||||
chart: t('Chart'),
|
||||
dashboard: t('Dashboard'),
|
||||
dataset: t('Dataset'),
|
||||
// Getters, not strings: the dataset label follows the SEMANTIC_LAYERS flag
|
||||
// ("Dataset" / "Datasource"), read at render time via the shared naming module.
|
||||
const TYPE_LABELS: Record<ArchivedType, () => string> = {
|
||||
chart: () => t('Chart'),
|
||||
dashboard: () => t('Dashboard'),
|
||||
dataset: datasetLabel,
|
||||
};
|
||||
|
||||
interface ToastProps {
|
||||
@@ -166,7 +169,7 @@ function ArchivedListBody({
|
||||
refreshData,
|
||||
} = useListViewResource<ArchivedItem>(
|
||||
config.resource,
|
||||
TYPE_LABELS[type],
|
||||
TYPE_LABELS[type](),
|
||||
addDangerToast,
|
||||
true,
|
||||
[],
|
||||
@@ -247,7 +250,7 @@ function ArchivedListBody({
|
||||
name => {
|
||||
const { text, options } = recoveredToast(
|
||||
name,
|
||||
TYPE_LABELS[type],
|
||||
TYPE_LABELS[type](),
|
||||
item.url ?? item.explore_url,
|
||||
);
|
||||
addSuccessToast(text, options);
|
||||
@@ -306,7 +309,7 @@ function ArchivedListBody({
|
||||
id: config.nameField,
|
||||
},
|
||||
{
|
||||
Cell: () => TYPE_LABELS[type],
|
||||
Cell: () => TYPE_LABELS[type](),
|
||||
Header: t('Type'),
|
||||
id: 'type',
|
||||
disableSortBy: true,
|
||||
@@ -539,7 +542,7 @@ function ArchivedList({ addDangerToast, addSuccessToast }: ToastProps) {
|
||||
onChange={handleTypeChange}
|
||||
options={availableTypes.map(option => ({
|
||||
value: option,
|
||||
label: TYPE_LABELS[option],
|
||||
label: TYPE_LABELS[option](),
|
||||
}))}
|
||||
/>
|
||||
</TypeSelectRow>
|
||||
|
||||
Reference in New Issue
Block a user