Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Code
e27418c926 fix(menu): highlight active nav tab in non-English locales
The top navigation highlighted the active tab by matching the current
route against hardcoded English strings ("Dashboards", "Charts", "SQL",
etc.) while menu items were keyed by their localized labels. In any
non-English locale the localized label never matched the English key, so
no tab was highlighted (issue #36403).

Key each menu item by its stable Flask-AppBuilder `name` (which is
locale-independent) instead of its displayed label, and match the active
tab against those same names. Highlighting now works regardless of the
selected interface language.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 22:43:00 -07:00
Claude Code
5342d96b0c fix(tags): remove unsatisfiable foreign keys from tagged_object.object_id
The tagged_object.object_id column declared three foreign keys at once
(dashboards.id, slices.id, saved_query.id). These constraints are
conjunctive, so a row can only be inserted if the id exists in all three
tables simultaneously, which is impossible. On a schema that enforces
them (e.g. one built from the models via create_all), every tag insert
fails, e.g. tagging or creating a dashboard with TAGGING_SYSTEM enabled
raises a ForeignKeyViolation against the slices table.

object_id is a polymorphic reference disambiguated by object_type and
should carry no foreign key, matching the original migration
c82ee8a39623. The ORM tag relationships resolve direction from their
explicit primaryjoin/secondaryjoin conditions and the tag_id FK, so they
are unaffected.

Fixes #35941

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 11:43:17 -07:00
4 changed files with 152 additions and 14 deletions

View File

@@ -796,3 +796,105 @@ test('brand link falls back to brand.path when theme brandLogoUrl is absent', as
// ensureAppRoot must have been applied: /welcome/ → /superset/welcome/
expect(brandLink).toHaveAttribute('href', '/superset/welcome/');
});
// --- Active tab highlighting (regression tests for issue #36403) ---
//
// The active top-level tab is highlighted by matching the current route to a
// menu item. The matching must rely on a stable identifier (the FAB `name`),
// not the displayed label, otherwise highlighting breaks for any non-English
// locale where the label is translated.
// Returns the top-level <li> that contains the given visible text, so we can
// assert whether antd marked it as the selected menu item.
const getMenuItemByText = (text: string): HTMLElement | null =>
screen.getByText(text).closest('li');
afterEach(() => {
// Reset the route so a pushed path does not leak into the next test.
window.history.pushState({}, '', '/');
});
test('highlights the active top-level tab on a matching route (English)', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
window.history.pushState({}, '', '/dashboard/list/');
render(<Menu {...mockedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
await screen.findByText('Dashboards');
expect(getMenuItemByText('Dashboards')).toHaveClass('ant-menu-item-selected');
});
test('highlights the active top-level tab when the label is localized', async () => {
// Russian locale: the FAB `name` stays the stable English identifier while
// the displayed `label` is translated. Highlighting must still work.
const localizedProps = {
...mockedProps,
data: {
...mockedProps.data,
menu: mockedProps.data.menu.map(item =>
item.name === 'Dashboards' ? { ...item, label: 'Дашборды' } : item,
),
},
};
useSelectorMock.mockReturnValue({ roles: user.roles });
window.history.pushState({}, '', '/dashboard/list/');
render(<Menu {...localizedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
await screen.findByText('Дашборды');
expect(getMenuItemByText('Дашборды')).toHaveClass('ant-menu-item-selected');
});
test('highlights the active SQL tab when the label is localized', async () => {
// The SQL Lab top-level entry is a FAB category: its stable `name` is
// "SQL Lab" while its label ("SQL") is localized.
const localizedProps = {
...mockedProps,
data: {
...mockedProps.data,
menu: [
...mockedProps.data.menu,
{
name: 'SQL Lab',
icon: 'fa-flask',
label: 'SQL запросы',
childs: [
{
name: 'SQL Editor',
label: 'SQL Lab',
url: '/sqllab/',
index: 1,
},
],
},
],
},
};
useSelectorMock.mockReturnValue({ roles: user.roles });
window.history.pushState({}, '', '/sqllab/');
render(<Menu {...localizedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
await screen.findByText('SQL запросы');
// SQL Lab renders as a submenu, so antd marks it with the submenu variant.
expect(getMenuItemByText('SQL запросы')).toHaveClass(
'ant-menu-submenu-selected',
);
});

View File

@@ -211,6 +211,16 @@ export function Menu({
SavedQueries = '/savedqueryview',
}
// Stable Flask-AppBuilder menu identifiers (`name`), used as menu item keys.
// These are locale-independent, unlike the displayed labels, so matching the
// active tab against them keeps highlighting working in every language.
enum MenuKeys {
Dashboards = 'Dashboards',
Charts = 'Charts',
Datasets = 'Datasets',
SqlLab = 'SQL Lab',
}
const defaultTabSelection: string[] = [];
const [activeTabs, setActiveTabs] = useState(defaultTabSelection);
const location = useLocation();
@@ -218,16 +228,16 @@ export function Menu({
const path = location.pathname;
switch (true) {
case path.startsWith(Paths.Dashboard):
setActiveTabs(['Dashboards']);
setActiveTabs([MenuKeys.Dashboards]);
break;
case path.startsWith(Paths.Chart) || path.startsWith(Paths.Explore):
setActiveTabs(['Charts']);
setActiveTabs([MenuKeys.Charts]);
break;
case path.startsWith(Paths.Datasets):
setActiveTabs([datasetsLabel()]);
setActiveTabs([MenuKeys.Datasets]);
break;
case path.startsWith(Paths.SqlLab) || path.startsWith(Paths.SavedQueries):
setActiveTabs(['SQL']);
setActiveTabs([MenuKeys.SqlLab]);
break;
default:
setActiveTabs(defaultTabSelection);
@@ -242,10 +252,14 @@ export function Menu({
childs,
url,
isFrontendRoute,
name,
}: MenuObjectProps): MenuItem => {
// Key items by the stable FAB `name` so active-tab matching is independent
// of the localized label. Fall back to the label when no name is provided.
const key = name ?? label;
if (url && isFrontendRoute) {
return {
key: label,
key,
label: (
<NavLink role="button" to={url} activeClassName="is-active">
{label}
@@ -256,7 +270,7 @@ export function Menu({
if (url) {
return {
key: label,
key,
label: <Typography.Link href={url}>{label}</Typography.Link>,
};
}
@@ -280,7 +294,7 @@ export function Menu({
});
return {
key: label,
key,
label,
...(screens.md && {
icon: <Icons.DownOutlined iconSize="xs" />,

View File

@@ -111,12 +111,14 @@ class TaggedObject(Model, AuditMixinNullable):
__tablename__ = "tagged_object"
id = Column(Integer, primary_key=True)
tag_id = Column(Integer, ForeignKey("tag.id"))
object_id = Column(
Integer,
ForeignKey("dashboards.id"),
ForeignKey("slices.id"),
ForeignKey("saved_query.id"),
)
# ``object_id`` is a polymorphic reference disambiguated by ``object_type``;
# the same value can point at a dashboard, chart or saved query. It must not
# carry a foreign key to any single table: declaring FKs to dashboards,
# slices and saved_query at once is unsatisfiable (a row would have to exist
# in all three) and breaks tagging, e.g. tagging a dashboard fails the
# slices FK. The original migration (c82ee8a39623) defined no FK here. See
# issue #35941.
object_id = Column(Integer)
object_type = Column(Enum(ObjectType))
tag = relationship("Tag", back_populates="objects", overlaps="tags")

View File

@@ -21,7 +21,7 @@ from unittest.mock import MagicMock
from markupsafe import Markup
from sqlalchemy.orm import Session
from superset.tags.models import get_tag, Tag, TagType
from superset.tags.models import get_tag, Tag, TaggedObject, TagType
def test_get_tag_returns_plain_string_not_markup() -> None:
@@ -261,3 +261,23 @@ def test_tag_name_type_after_database_operation() -> None:
assert added_tag.name.__class__ is str, (
"Tag name should be exactly str type, not a subclass"
)
def test_tagged_object_object_id_has_no_foreign_keys() -> None:
"""
``tagged_object.object_id`` is a polymorphic reference disambiguated by
``object_type`` and must not carry a foreign key to any single table.
Declaring foreign keys to ``dashboards``, ``slices`` and ``saved_query`` on
the same column is unsatisfiable: a row would have to exist in all three
tables at once, so on a schema that enforces those constraints every tag
insert fails (e.g. tagging a dashboard violates the ``slices`` FK). This is
the regression behind issue #35941. The original migration (c82ee8a39623)
defined ``object_id`` without any FK, and this guards against the model
drifting back.
"""
foreign_keys = TaggedObject.__table__.c.object_id.foreign_keys
assert foreign_keys == set(), (
"tagged_object.object_id must not declare foreign keys; it is a "
"polymorphic reference resolved via object_type"
)