Compare commits

...

5 Commits

Author SHA1 Message Date
Joe Li
7d1497ece3 test(ci): stabilize master checks 2026-07-02 13:41:22 -07:00
Mehmet Salih Yavuz
8bf3933972 fix(dashboard): show a not-found state for a deleted dashboard (#41686) 2026-07-02 22:15:25 +03:00
yousoph
19e94855a1 fix(explore): prevent Results FilterInput from stealing focus during remount (#41100)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 11:33:17 -07:00
Brian Maina
139df20cde fix(i18n): update German security menu translations (#41587) 2026-07-03 01:13:14 +07:00
Imad Helal
4c193d4dbc feat(i18n): wrap description strings in translation function (#41626) 2026-07-03 00:44:25 +07:00
13 changed files with 153 additions and 26 deletions

View File

@@ -29,6 +29,7 @@ import { waitForPost } from '../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../helpers/api/assertions';
import { getDatabaseByName } from '../../helpers/api/database';
import { apiExecuteSql } from '../../helpers/api/sqllab';
import { TIMEOUT } from '../../utils/constants';
interface ExamplesSetupResult {
tableName: string;
@@ -116,7 +117,7 @@ async function dropTempTable(
// Uses test.describe only because Playwright's serial mode API requires it -
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
test.describe('create dataset wizard', () => {
test.describe.configure({ mode: 'serial' });
test.describe.configure({ mode: 'serial', timeout: TIMEOUT.SLOW_TEST });
test('should create a dataset via wizard', async ({ page, testAssets }) => {
const { tableName, dbId, createDatasetPage } = await setupExamplesDataset(

View File

@@ -33,8 +33,9 @@ import { getLayerConfig } from '../util/controlPanelUtil';
export default class CartodiagramPlugin extends ChartPlugin {
constructor(opts: CartodiagramPluginConstructorOpts) {
const metadata = new ChartMetadata({
description:
description: t(
'Display charts on a map. For using this plugin, users first have to create any other chart that can then be placed on the map.',
),
name: t('Cartodiagram'),
thumbnail,
thumbnailDark,

View File

@@ -28,8 +28,9 @@ export default class PopKPIPlugin extends ChartPlugin {
constructor() {
const metadata = new ChartMetadata({
category: t('KPI'),
description:
description: t(
'Showcases a metric along with a comparison of value, change, and percent change for a selected time period.',
),
name: t('Big Number with Time Period Comparison'),
tags: [
t('Comparison'),

View File

@@ -22,6 +22,7 @@ import {
createStore,
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import reducerIndex from 'spec/helpers/reducerIndex';
@@ -30,7 +31,7 @@ import {
useDashboardCharts,
useDashboardDatasets,
} from 'src/hooks/apiResources';
import { SupersetClient } from '@superset-ui/core';
import { SupersetApiError, SupersetClient } from '@superset-ui/core';
import CrudThemeProvider from 'src/components/CrudThemeProvider';
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
import {
@@ -559,6 +560,48 @@ test('does not overwrite filterState when modern native_filters URL format is us
).toBeUndefined();
});
test('renders a not-found state instead of throwing when the dashboard 404s', async () => {
mockUseDashboard.mockReturnValue({
result: null,
error: new SupersetApiError({ status: 404, message: 'Not found' }),
});
mockUseDashboardCharts.mockReturnValue({
result: null,
error: new SupersetApiError({ status: 404, message: 'Not found' }),
});
mockUseDashboardDatasets.mockReturnValue({
result: null,
error: new SupersetApiError({ status: 404, message: 'Not found' }),
status: 'error',
});
render(
<Suspense fallback="loading">
<DashboardPage idOrSlug="404" />
</Suspense>,
{
useRedux: true,
useRouter: true,
initialState: {
dashboardInfo: {},
dashboardState: { sliceIds: [] },
nativeFilters: { filters: {} },
dataMask: {},
},
},
);
expect(
await screen.findByText('This dashboard does not exist'),
).toBeInTheDocument();
expect(screen.queryByTestId('dashboard-builder')).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', { name: 'See all dashboards' }),
);
expect(window.location.pathname).toBe('/dashboard/list/');
});
test('clears undo history after hydrating the dashboard', async () => {
render(
<Suspense fallback="loading">

View File

@@ -24,7 +24,7 @@ import { useTheme } from '@apache-superset/core/theme';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { Loading } from '@superset-ui/core/components';
import { EmptyState, Loading } from '@superset-ui/core/components';
import {
useDashboard,
useDashboardCharts,
@@ -67,7 +67,8 @@ import SyncDashboardState, {
getDashboardContextLocalStorage,
} from '../components/SyncDashboardState';
import { AutoRefreshProvider } from '../contexts/AutoRefreshContext';
import { Filter, PartialFilters } from '@superset-ui/core';
import { Filter, PartialFilters, SupersetApiError } from '@superset-ui/core';
import { RoutePaths } from 'src/views/routePaths';
import {
parseRisonFilters,
risonFiltersToExtraFormDataFilters,
@@ -151,6 +152,9 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
const isDashboardHydrated = useRef(false);
const error = dashboardApiError || chartsApiError;
// Only 404 gets a graceful not-found state; a 403 (access denied) still
// surfaces through the error boundary.
const isNotFoundError = (error as SupersetApiError | null)?.status === 404;
const readyToRender = Boolean(dashboard && charts);
const { dashboard_title, id = 0 } = dashboard || {};
@@ -365,18 +369,21 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
useEffect(() => {
if (datasetsApiError) {
addDangerToast(
t('Error loading chart datasources. Filters may not work correctly.'),
);
// A missing dashboard also 404s its datasets; the not-found state covers it.
if (!isNotFoundError) {
addDangerToast(
t('Error loading chart datasources. Filters may not work correctly.'),
);
}
} else {
dispatch(setDatasources(datasets));
}
}, [addDangerToast, datasets, datasetsApiError, dispatch]);
}, [addDangerToast, datasets, datasetsApiError, dispatch, isNotFoundError]);
const relevantDataMask = useSelector(selectRelevantDatamask);
const activeFilters = useSelector(selectActiveFilters);
if (error) throw error; // caught in error boundary
if (error && !isNotFoundError) throw error; // caught in error boundary
const globalStyles = useMemo(
() => [
@@ -389,9 +396,25 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
[theme],
);
if (error) throw error; // caught in error boundary
if (error && !isNotFoundError) throw error; // caught in error boundary
const DashboardBuilderComponent = useMemo(() => <DashboardBuilder />, []);
if (isNotFoundError) {
return (
<EmptyState
size="large"
image="empty-dashboard.svg"
title={t('This dashboard does not exist')}
description={t(
'The dashboard you are looking for may have been deleted or moved.',
)}
buttonText={t('See all dashboards')}
buttonAction={() => history.push(RoutePaths.DASHBOARD_LIST)}
/>
);
}
return (
<>
<Global styles={globalStyles} />

View File

@@ -34,3 +34,40 @@ test('Render a FilterInput', async () => {
expect(onChangeHandler).toHaveBeenCalledTimes(4);
});
test('FilterInput auto-focuses when a non-editable element (e.g. a tab) has focus', () => {
const onChangeHandler = jest.fn();
const button = document.createElement('button');
document.body.appendChild(button);
try {
button.focus();
expect(document.activeElement).toBe(button);
render(<FilterInput onChangeHandler={onChangeHandler} shouldFocus />);
const filterInput = screen.getByPlaceholderText('Search');
// Auto-focus should fire — a button is not an editable element
expect(document.activeElement).toBe(filterInput);
} finally {
document.body.removeChild(button);
}
});
test('FilterInput does not steal focus when another input already has focus', () => {
const onChangeHandler = jest.fn();
const otherInput = document.createElement('input');
document.body.appendChild(otherInput);
try {
otherInput.focus();
expect(document.activeElement).toBe(otherInput);
render(<FilterInput onChangeHandler={onChangeHandler} shouldFocus />);
const filterInput = screen.getByPlaceholderText('Search');
// FilterInput should not have stolen focus from the already-focused input
expect(document.activeElement).not.toBe(filterInput);
expect(document.activeElement).toBe(otherInput);
} finally {
document.body.removeChild(otherInput);
}
});

View File

@@ -98,9 +98,20 @@ export const FilterInput = ({
const inputRef: RefObject<any> = useRef(null);
useEffect(() => {
// Focus the input element when the component mounts
if (inputRef.current && shouldFocus) {
inputRef.current.focus();
// Skip auto-focus only when an editable element already has focus (e.g.
// user is typing in a form control when this pane remounts after a data
// refresh). Non-editable focused elements like tabs/buttons still allow
// auto-focus so the search box focuses on first open.
const activeEl = document.activeElement;
const editableFocused =
activeEl instanceof HTMLElement &&
(activeEl.tagName === 'INPUT' ||
activeEl.tagName === 'TEXTAREA' ||
activeEl.isContentEditable);
if (!editableFocused) {
inputRef.current.focus();
}
}
}, []);

View File

@@ -541,8 +541,9 @@ function SavedQueryList({
key: 'search',
input: 'search',
operator: FilterOperator.AllText,
toolTipDescription:
toolTipDescription: t(
'Searches all text fields: Name, Description, Database & Schema',
),
},
{
Header: t('Database'),

View File

@@ -133,8 +133,9 @@ function TagList(props: TagListProps) {
const emptyState = {
title: t('No Tags created'),
image: 'dashboard.svg',
description:
description: t(
'Create a new tag and assign it to existing entities like charts or dashboards',
),
buttonAction: () => setShowTagModal(true),
buttonIcon: <Icons.PlusOutlined iconSize="m" data-test="add-rule-empty" />,
buttonText: t('Create a new Tag'),

View File

@@ -8250,16 +8250,16 @@ msgid "List"
msgstr "Auflisten"
msgid "List Groups"
msgstr "Gruppen"
msgstr "Gruppen auflisten"
msgid "List Roles"
msgstr "Rollen"
msgstr "Rollen auflisten"
msgid "List Unique Values"
msgstr "Eindeutige Werte auflisten"
msgid "List Users"
msgstr "Benutzer*innen"
msgstr "Benutzer*innen auflisten"
msgid "List of extra columns made available in JavaScript functions"
msgstr ""

View File

@@ -17,6 +17,7 @@
"""Unit tests for the MCP get_dashboard_datasets tool."""
from importlib import import_module
from unittest.mock import Mock, patch
import pytest
@@ -29,6 +30,10 @@ from superset.mcp_service.utils.sanitization import (
)
from superset.utils import json
get_dashboard_datasets_module = import_module(
"superset.mcp_service.dashboard.tool.get_dashboard_datasets"
)
def _wrapped(value: str) -> str:
return f"{LLM_CONTEXT_OPEN_DELIMITER}\n{value}\n{LLM_CONTEXT_CLOSE_DELIMITER}"
@@ -142,8 +147,8 @@ def mock_dataset_access():
@pytest.fixture(autouse=True)
def allow_data_model_metadata():
"""Keep tests in the metadata-allowed path unless a test overrides it."""
with patch(
"superset.mcp_service.dashboard.tool.get_dashboard_datasets."
with patch.object(
get_dashboard_datasets_module,
"user_can_view_data_model_metadata",
return_value=True,
) as mock_allow:

View File

@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from importlib import import_module
from importlib.util import find_spec
from unittest.mock import patch
@@ -26,6 +27,8 @@ from superset.utils.core import DTTM_ALIAS
from superset.utils.pandas_postprocessing import prophet
from tests.unit_tests.fixtures.dataframes import prophet_df
prophet_module = import_module("superset.utils.pandas_postprocessing.prophet")
def test_prophet_valid():
df = prophet(df=prophet_df, time_grain="P1M", periods=3, confidence_interval=0.9)
@@ -207,9 +210,7 @@ def test_prophet_fit_error():
if find_spec("prophet") is None:
pytest.skip("prophet not installed")
with patch(
"superset.utils.pandas_postprocessing.prophet._prophet_fit_and_predict"
) as mock_fit:
with patch.object(prophet_module, "_prophet_fit_and_predict") as mock_fit:
mock_fit.side_effect = InvalidPostProcessingError(
"Unable to generate forecast: Dataframe has fewer than 2 non-NaN rows."
)

View File

@@ -2983,8 +2983,9 @@ def test_coerce_integer_rejects_non_integer_float() -> None:
def test_coerce_integer_rejects_other_types() -> None:
raw: Any = [1]
with pytest.raises(ValueError, match="Invalid integer value"):
_coerce_scalar_filter_value([1], _dim(pa.int64()))
_coerce_scalar_filter_value(raw, _dim(pa.int64()))
@pytest.mark.parametrize(
@@ -3008,8 +3009,9 @@ def test_coerce_floating_invalid_string_raises() -> None:
def test_coerce_floating_rejects_other_types() -> None:
raw: Any = [1.0]
with pytest.raises(ValueError, match="Invalid numeric value"):
_coerce_scalar_filter_value([1.0], _dim(pa.float64()))
_coerce_scalar_filter_value(raw, _dim(pa.float64()))
def test_coerce_date_from_datetime() -> None: