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)

Reply via email to