mirror of
https://github.com/apache/superset.git
synced 2026-07-02 21:05:36 +00:00
Compare commits
5 Commits
feat/faste
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d1497ece3 | ||
|
|
8bf3933972 | ||
|
|
19e94855a1 | ||
|
|
139df20cde | ||
|
|
4c193d4dbc |
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
37
superset-frontend/src/explore/components/DataTableControl/FilterInput.test.tsx
Normal file → Executable file
37
superset-frontend/src/explore/components/DataTableControl/FilterInput.test.tsx
Normal file → Executable 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);
|
||||
}
|
||||
});
|
||||
|
||||
15
superset-frontend/src/explore/components/DataTableControl/index.tsx
Normal file → Executable file
15
superset-frontend/src/explore/components/DataTableControl/index.tsx
Normal file → Executable 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();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user