mirror of
https://github.com/apache/superset.git
synced 2026-06-18 05:59:21 +00:00
Compare commits
2 Commits
docs/fix-p
...
fix/36403-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e27418c926 | ||
|
|
5342d96b0c |
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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" />,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user