This is an automated email from the ASF dual-hosted git repository.
yousoph pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new c0b0a2fdaf5 feat(tags): add favorites filter to Tags list view (#41461)
c0b0a2fdaf5 is described below
commit c0b0a2fdaf5782b902229d16b0dd0266c90fb0be
Author: yousoph <[email protected]>
AuthorDate: Mon Jul 6 12:55:01 2026 -0700
feat(tags): add favorites filter to Tags list view (#41461)
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
superset-frontend/src/components/ListView/types.ts | 1 +
superset-frontend/src/pages/Tags/index.tsx | 21 +++++++++++-
superset/tags/api.py | 16 +++++++--
superset/tags/filters.py | 30 ++++++++++++++--
tests/integration_tests/tags/api_tests.py | 40 ++++++++++++++++++++++
5 files changed, 103 insertions(+), 5 deletions(-)
diff --git a/superset-frontend/src/components/ListView/types.ts
b/superset-frontend/src/components/ListView/types.ts
index f1f2ad28ced..c1caf2f9340 100644
--- a/superset-frontend/src/components/ListView/types.ts
+++ b/superset-frontend/src/components/ListView/types.ts
@@ -129,6 +129,7 @@ export enum ListViewFilterOperator {
Between = 'between',
DashboardIsFav = 'dashboard_is_favorite',
ChartIsFav = 'chart_is_favorite',
+ TagIsFav = 'tag_is_favorite',
ChartIsCertified = 'chart_is_certified',
DashboardIsCertified = 'dashboard_is_certified',
DatasetIsCertified = 'dataset_is_certified',
diff --git a/superset-frontend/src/pages/Tags/index.tsx
b/superset-frontend/src/pages/Tags/index.tsx
index fd791c5d3ca..ffd7bd591cc 100644
--- a/superset-frontend/src/pages/Tags/index.tsx
+++ b/superset-frontend/src/pages/Tags/index.tsx
@@ -35,6 +35,7 @@ import {
ListView,
ModifiedInfo,
ListViewFilterOperator as FilterOperator,
+ type ListViewFilter,
type ListViewFilters,
type ListViewProps,
} from 'src/components';
@@ -269,6 +270,23 @@ function TagList(props: TagListProps) {
],
);
+ const favoritesFilter: ListViewFilter = useMemo(
+ () => ({
+ Header: t('Favorite'),
+ key: 'favorite',
+ id: 'id',
+ urlDisplay: 'favorite',
+ input: 'select',
+ operator: FilterOperator.TagIsFav,
+ unfilteredLabel: t('Any'),
+ selects: [
+ { label: t('Yes'), value: true },
+ { label: t('No'), value: false },
+ ],
+ }),
+ [],
+ );
+
const filters: ListViewFilters = useMemo(() => {
const filters_list = [
{
@@ -278,6 +296,7 @@ function TagList(props: TagListProps) {
operator: FilterOperator.Contains,
inputName: 'tag_list_search',
},
+ ...(userId ? [favoritesFilter] : []),
{
Header: t('Modified by'),
key: 'changed_by',
@@ -300,7 +319,7 @@ function TagList(props: TagListProps) {
},
] as ListViewFilters;
return filters_list;
- }, [addDangerToast, props.user]);
+ }, [addDangerToast, props.user, userId, favoritesFilter]);
const sortTypes = [
{
diff --git a/superset/tags/api.py b/superset/tags/api.py
index 04b95f885ce..635438299cb 100644
--- a/superset/tags/api.py
+++ b/superset/tags/api.py
@@ -40,7 +40,7 @@ from superset.constants import
MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.daos.tag import TagDAO
from superset.exceptions import MissingUserContextException
from superset.extensions import event_logger
-from superset.tags.filters import UserCreatedTagTypeFilter
+from superset.tags.filters import TagFavoriteFilter, UserCreatedTagTypeFilter
from superset.tags.models import ObjectType, Tag
from superset.tags.schemas import (
delete_tags_schema,
@@ -120,7 +120,19 @@ class TagRestApi(BaseSupersetModelRestApi):
}
allowed_rel_fields = {"created_by", "changed_by"}
- search_filters = {"type": [UserCreatedTagTypeFilter]}
+ search_columns = [
+ "id",
+ "name",
+ "type",
+ "description",
+ "created_by",
+ "changed_by",
+ ]
+
+ search_filters = {
+ "type": [UserCreatedTagTypeFilter],
+ "id": [TagFavoriteFilter],
+ }
add_model_schema = TagPostSchema()
edit_model_schema = TagPutSchema()
diff --git a/superset/tags/filters.py b/superset/tags/filters.py
index e1e62cf6fdf..6ed6057a409 100644
--- a/superset/tags/filters.py
+++ b/superset/tags/filters.py
@@ -22,11 +22,12 @@ from flask_babel import lazy_gettext as _
from sqlalchemy.orm import Query
from superset.connectors.sqla.models import SqlaTable
-from superset.extensions import db
+from superset.extensions import db, security_manager
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.sql_lab import Query as SqllabQuery
-from superset.tags.models import Tag, TagType
+from superset.tags.models import Tag, TagType, user_favorite_tag_table
+from superset.utils.core import get_user_id
from superset.views.base import BaseFilter
@@ -48,6 +49,31 @@ class UserCreatedTagTypeFilter(BaseFilter): # pylint:
disable=too-few-public-me
return query
+class TagFavoriteFilter(BaseFilter): # pylint: disable=too-few-public-methods
+ """
+ Custom filter for the GET list that filters tags the current user has
+ favorited or not.
+
+ Tag favorites are stored in the dedicated ``user_favorite_tag_table`` M2M
+ table rather than in ``FavStar``, so this filter cannot reuse
+ ``BaseFavoriteFilter`` (which queries ``FavStar``).
+ """
+
+ name = _("Is favorite")
+ arg_name = "tag_is_favorite"
+
+ def apply(self, query: Query, value: Any) -> Query:
+ # If anonymous user filter nothing
+ if security_manager.current_user is None:
+ return query
+ users_favorite_query = db.session.query(
+ user_favorite_tag_table.c.tag_id
+ ).filter(user_favorite_tag_table.c.user_id == get_user_id())
+ if value:
+ return query.filter(Tag.id.in_(users_favorite_query))
+ return query.filter(~Tag.id.in_(users_favorite_query))
+
+
class BaseTagNameFilter(BaseFilter): # pylint: disable=too-few-public-methods
"""
Base Custom filter for the GET list that filters all dashboards, slices
diff --git a/tests/integration_tests/tags/api_tests.py
b/tests/integration_tests/tags/api_tests.py
index 96d54dc9f25..ee3570ffee7 100644
--- a/tests/integration_tests/tags/api_tests.py
+++ b/tests/integration_tests/tags/api_tests.py
@@ -605,6 +605,46 @@ class TestTagApi(InsertChartMixin, SupersetTestCase):
assert association_row is None
+ @pytest.mark.usefixtures("create_tags")
+ def test_get_list_tag_filtered_by_favorite(self):
+ """
+ Tag API: Test get list filtered by the ``tag_is_favorite`` filter
+ returns only the tags the current user has (or has not) favorited.
+ """
+ self.login(ADMIN_USERNAME)
+ # favorite a single tag for the current (admin) user
+ favorited_tag = db.session.query(Tag).first()
+ rv = self.client.post(
+ f"api/v1/tag/{favorited_tag.id}/favorites/", follow_redirects=True
+ )
+ assert rv.status_code == 200
+
+ # value=True returns only the favorited tag
+ query = {
+ "filters": [{"col": "id", "opr": "tag_is_favorite", "value":
True}],
+ }
+ uri = f"api/v1/tag/?{parse.urlencode({'q': rison.dumps(query)})}"
+ rv = self.client.get(uri)
+ assert rv.status_code == 200
+ data = json.loads(rv.data.decode("utf-8"))
+ assert data["count"] == 1
+ assert data["result"][0]["id"] == favorited_tag.id
+
+ # value=False returns every other tag
+ query["filters"][0]["value"] = False
+ uri = f"api/v1/tag/?{parse.urlencode({'q': rison.dumps(query)})}"
+ rv = self.client.get(uri)
+ assert rv.status_code == 200
+ data = json.loads(rv.data.decode("utf-8"))
+ assert data["count"] == TAGS_FIXTURE_COUNT - 1
+ assert favorited_tag.id not in {tag["id"] for tag in data["result"]}
+
+ # cleanup the favorite association
+ rv = self.client.delete(
+ f"api/v1/tag/{favorited_tag.id}/favorites/", follow_redirects=True
+ )
+ assert rv.status_code == 200
+
@pytest.mark.usefixtures("create_tags")
def test_add_tag_not_found(self):
self.login(ADMIN_USERNAME)