This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new ba7b407455c Add is_favorite to ui dags list (#56341)
ba7b407455c is described below

commit ba7b407455c2d6ead4eba54ad75ab1f0156056b3
Author: Brent Bovenzi <[email protected]>
AuthorDate: Thu Oct 9 16:31:24 2025 -0400

    Add is_favorite to ui dags list (#56341)
    
    * Add is_favorite to ui dags list
    
    * Update dagdetails
---
 .../api_fastapi/core_api/datamodels/dags.py        |  1 +
 .../api_fastapi/core_api/datamodels/ui/dags.py     |  1 +
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |  4 ++
 .../core_api/openapi/v2-rest-api-generated.yaml    |  4 ++
 .../api_fastapi/core_api/routes/public/dags.py     | 18 +++++-
 .../airflow/api_fastapi/core_api/routes/ui/dags.py | 11 ++++
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 11 +++-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  2 +
 .../components/DagActions/FavoriteDagButton.tsx    | 33 +++++------
 .../src/airflow/ui/src/mocks/handlers/dags.ts      |  4 ++
 .../src/airflow/ui/src/pages/Dag/Header.tsx        |  2 +-
 .../airflow/ui/src/pages/DagsList/DagCard.test.tsx |  1 +
 .../src/airflow/ui/src/pages/DagsList/DagCard.tsx  |  2 +-
 .../src/airflow/ui/src/pages/DagsList/DagsList.tsx |  5 +-
 .../src/airflow/ui/src/queries/useFavoriteDag.ts   | 33 -----------
 .../airflow/ui/src/queries/useToggleFavoriteDag.ts | 66 ++++++++++++++++++++++
 .../src/airflow/ui/src/queries/useUnfavoriteDag.ts | 33 -----------
 .../core_api/routes/public/test_dags.py            | 41 ++++++++++++++
 .../api_fastapi/core_api/routes/ui/test_dags.py    | 36 ++++++++++++
 .../src/airflowctl/api/datamodels/generated.py     |  1 +
 20 files changed, 217 insertions(+), 92 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
index 23aa4ec300f..2d14f29c41a 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
@@ -157,6 +157,7 @@ class DAGDetailsResponse(DAGResponse):
     last_parsed: datetime | None
     default_args: abc.Mapping | None
     owner_links: dict[str, str] | None = None
+    is_favorite: bool = False
 
     @field_validator("timezone", mode="before")
     @classmethod
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
index e76c5414d6d..97221f6192d 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
@@ -29,6 +29,7 @@ class DAGWithLatestDagRunsResponse(DAGResponse):
     asset_expression: dict | None
     latest_dag_runs: list[DAGRunResponse]
     pending_actions: list[HITLDetail]
+    is_favorite: bool
 
 
 class DAGWithLatestDagRunsCollectionResponse(BaseModel):
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 549ce5ae055..a3858d6fc2a 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -1686,6 +1686,9 @@ components:
             $ref: '#/components/schemas/HITLDetail'
           type: array
           title: Pending Actions
+        is_favorite:
+          type: boolean
+          title: Is Favorite
         file_token:
           type: string
           title: File Token
@@ -1721,6 +1724,7 @@ components:
       - asset_expression
       - latest_dag_runs
       - pending_actions
+      - is_favorite
       - file_token
       title: DAGWithLatestDagRunsResponse
       description: DAG with latest dag runs response serializer.
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index ac87cfdd7d0..88d5a1ff431 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -10017,6 +10017,10 @@ components:
             type: object
           - type: 'null'
           title: Owner Links
+        is_favorite:
+          type: boolean
+          title: Is Favorite
+          default: false
         file_token:
           type: string
           title: File Token
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
index e62c12c5ecb..a50c1b35069 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
@@ -209,7 +209,9 @@ def get_dag(
     ),
     dependencies=[Depends(requires_access_dag(method="GET"))],
 )
-def get_dag_details(dag_id: str, session: SessionDep, dag_bag: DagBagDep) -> 
DAGDetailsResponse:
+def get_dag_details(
+    dag_id: str, session: SessionDep, dag_bag: DagBagDep, user: GetUserDep
+) -> DAGDetailsResponse:
     """Get details of DAG."""
     dag = get_latest_version_of_dag(dag_bag, dag_id, session)
 
@@ -221,7 +223,19 @@ def get_dag_details(dag_id: str, session: SessionDep, 
dag_bag: DagBagDep) -> DAG
         if not key.startswith("_") and not hasattr(dag_model, key):
             setattr(dag_model, key, value)
 
-    return dag_model
+    # Check if this DAG is marked as favorite by the current user
+    user_id = str(user.get_id())
+    is_favorite = (
+        session.scalar(
+            select(DagFavorite.dag_id).where(DagFavorite.user_id == user_id, 
DagFavorite.dag_id == dag_id)
+        )
+        is not None
+    )
+
+    # Add is_favorite field to the DAG model
+    setattr(dag_model, "is_favorite", is_favorite)
+
+    return DAGDetailsResponse.model_validate(dag_model)
 
 
 @dags_router.patch(
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
index af4ed341f7f..ff20874b50f 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
@@ -61,10 +61,12 @@ from airflow.api_fastapi.core_api.datamodels.ui.dags import 
(
 )
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.core_api.security import (
+    GetUserDep,
     ReadableDagsFilterDep,
     requires_access_dag,
 )
 from airflow.models import DagModel, DagRun
+from airflow.models.dag_favorite import DagFavorite
 from airflow.models.hitl import HITLDetail
 from airflow.models.taskinstance import TaskInstance
 from airflow.utils.state import TaskInstanceState
@@ -114,6 +116,7 @@ def get_dags(
     has_pending_actions: QueryPendingActionsFilter,
     readable_dags_filter: ReadableDagsFilterDep,
     session: SessionDep,
+    user: GetUserDep,
     dag_runs_limit: int = 10,
 ) -> DAGWithLatestDagRunsCollectionResponse:
     """Get DAGs with recent DagRun."""
@@ -153,6 +156,13 @@ def get_dags(
 
     dags = [dag for dag in session.scalars(dags_select)]
 
+    # Fetch favorite status for each DAG for the current user
+    user_id = str(user.get_id())
+    favorites_select = select(DagFavorite.dag_id).where(
+        DagFavorite.user_id == user_id, DagFavorite.dag_id.in_([dag.dag_id for 
dag in dags])
+    )
+    favorite_dag_ids = set(session.scalars(favorites_select))
+
     # Populate the last 'dag_runs_limit' DagRuns for each DAG
     recent_runs_subquery = (
         select(
@@ -224,6 +234,7 @@ def get_dags(
                 "asset_expression": dag.asset_expression,
                 "latest_dag_runs": [],
                 "pending_actions": pending_actions_by_dag_id[dag.dag_id],
+                "is_favorite": dag.dag_id in favorite_dag_ids,
             }
         )
         for dag in dags
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 7d344a546cc..23b53707db2 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -2041,6 +2041,11 @@ export const $DAGDetailsResponse = {
             ],
             title: 'Owner Links'
         },
+        is_favorite: {
+            type: 'boolean',
+            title: 'Is Favorite',
+            default: false
+        },
         file_token: {
             type: 'string',
             title: 'File Token',
@@ -7343,6 +7348,10 @@ export const $DAGWithLatestDagRunsResponse = {
             type: 'array',
             title: 'Pending Actions'
         },
+        is_favorite: {
+            type: 'boolean',
+            title: 'Is Favorite'
+        },
         file_token: {
             type: 'string',
             title: 'File Token',
@@ -7351,7 +7360,7 @@ export const $DAGWithLatestDagRunsResponse = {
         }
     },
     type: 'object',
-    required: ['dag_id', 'dag_display_name', 'is_paused', 'is_stale', 
'last_parsed_time', 'last_parse_duration', 'last_expired', 'bundle_name', 
'bundle_version', 'relative_fileloc', 'fileloc', 'description', 
'timetable_summary', 'timetable_description', 'tags', 'max_active_tasks', 
'max_active_runs', 'max_consecutive_failed_dag_runs', 
'has_task_concurrency_limits', 'has_import_errors', 'next_dagrun_logical_date', 
'next_dagrun_data_interval_start', 'next_dagrun_data_interval_end', 'next_da 
[...]
+    required: ['dag_id', 'dag_display_name', 'is_paused', 'is_stale', 
'last_parsed_time', 'last_parse_duration', 'last_expired', 'bundle_name', 
'bundle_version', 'relative_fileloc', 'fileloc', 'description', 
'timetable_summary', 'timetable_description', 'tags', 'max_active_tasks', 
'max_active_runs', 'max_consecutive_failed_dag_runs', 
'has_task_concurrency_limits', 'has_import_errors', 'next_dagrun_logical_date', 
'next_dagrun_data_interval_start', 'next_dagrun_data_interval_end', 'next_da 
[...]
     title: 'DAGWithLatestDagRunsResponse',
     description: 'DAG with latest dag runs response serializer.'
 } as const;
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 0a719c75122..63e325717c4 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -562,6 +562,7 @@ export type DAGDetailsResponse = {
     owner_links?: {
     [key: string]: (string);
 } | null;
+    is_favorite?: boolean;
     /**
      * Return file token.
      */
@@ -1834,6 +1835,7 @@ export type DAGWithLatestDagRunsResponse = {
 } | null;
     latest_dag_runs: Array<DAGRunResponse>;
     pending_actions: Array<HITLDetail>;
+    is_favorite: boolean;
     /**
      * Return file token.
      */
diff --git 
a/airflow-core/src/airflow/ui/src/components/DagActions/FavoriteDagButton.tsx 
b/airflow-core/src/airflow/ui/src/components/DagActions/FavoriteDagButton.tsx
index a548d0bf8f5..38024ecddde 100644
--- 
a/airflow-core/src/airflow/ui/src/components/DagActions/FavoriteDagButton.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/DagActions/FavoriteDagButton.tsx
@@ -17,44 +17,39 @@
  * under the License.
  */
 import { Box } from "@chakra-ui/react";
-import { useCallback, useMemo } from "react";
 import { useTranslation } from "react-i18next";
 import { FiStar } from "react-icons/fi";
 
-import { useDagServiceGetDagsUi } from "openapi/queries";
-import { useFavoriteDag } from "src/queries/useFavoriteDag";
-import { useUnfavoriteDag } from "src/queries/useUnfavoriteDag";
+import { useToggleFavoriteDag } from "src/queries/useToggleFavoriteDag";
 
 import ActionButton from "../ui/ActionButton";
 
 type FavoriteDagButtonProps = {
   readonly dagId: string;
+  readonly isFavorite?: boolean;
   readonly withText?: boolean;
 };
 
-export const FavoriteDagButton = ({ dagId, withText = true }: 
FavoriteDagButtonProps) => {
+export const FavoriteDagButton = ({ dagId, isFavorite = false, withText = true 
}: FavoriteDagButtonProps) => {
   const { t: translate } = useTranslation("dags");
-  const { data: favorites } = useDagServiceGetDagsUi({ isFavorite: true });
 
-  const isFavorite = useMemo(
-    () => favorites?.dags.some((fav) => fav.dag_id === dagId) ?? false,
-    [favorites, dagId],
-  );
-
-  const { mutate: favoriteDag } = useFavoriteDag();
-  const { mutate: unfavoriteDag } = useUnfavoriteDag();
-
-  const onToggle = useCallback(() => {
-    const mutationFn = isFavorite ? unfavoriteDag : favoriteDag;
+  const { isLoading, toggleFavorite } = useToggleFavoriteDag(dagId);
 
-    mutationFn({ dagId });
-  }, [dagId, isFavorite, favoriteDag, unfavoriteDag]);
+  const onToggle = () => toggleFavorite(isFavorite);
 
   return (
     <Box>
       <ActionButton
         actionName={isFavorite ? translate("unfavoriteDag") : 
translate("favoriteDag")}
-        icon={<FiStar style={{ fill: isFavorite ? 
"var(--chakra-colors-brand-solid)" : "none" }} />}
+        icon={
+          <FiStar
+            style={{
+              fill: isFavorite ? "var(--chakra-colors-brand-solid)" : "none",
+              stroke: "var(--chakra-colors-brand-solid)",
+            }}
+          />
+        }
+        loading={isLoading}
         onClick={onToggle}
         text={isFavorite ? translate("unfavoriteDag") : 
translate("favoriteDag")}
         withText={withText}
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts
index 35ba89a46ce..75158b012c4 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts
@@ -32,6 +32,7 @@ export const handlers: Array<HttpHandler> = [
       fileloc: "/airflow/dags/tutorial_taskflow_api.py",
       has_import_errors: false,
       has_task_concurrency_limits: false,
+      is_favorite: true,
       is_paused: false,
       is_stale: false,
       last_parsed_time: "2025-01-13T07:34:01.593459Z",
@@ -69,6 +70,7 @@ export const handlers: Array<HttpHandler> = [
       fileloc: "/airflow/dags/tutorial_taskflow_api_failed.py",
       has_import_errors: false,
       has_task_concurrency_limits: false,
+      is_favorite: false,
       is_paused: false,
       is_stale: false,
       last_parsed_time: "2025-01-13T07:34:01.593459Z",
@@ -128,6 +130,7 @@ export const handlers: Array<HttpHandler> = [
       fileloc: "/airflow/dags/tutorial_taskflow_api_failed.py",
       has_import_errors: false,
       has_task_concurrency_limits: false,
+      is_favorite: false,
       is_paused: false,
       is_stale: false,
       last_expired: null,
@@ -155,6 +158,7 @@ export const handlers: Array<HttpHandler> = [
       fileloc: "/airflow/dags/tutorial_taskflow_api_success.py",
       has_import_errors: false,
       has_task_concurrency_limits: false,
+      is_favorite: true,
       is_paused: false,
       is_stale: false,
       last_expired: null,
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
index 1ca3b25c0de..8d55c0f0c39 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
@@ -128,7 +128,7 @@ export const Header = ({
                 text={translate("dag:header.buttons.dagDocs")}
               />
             )}
-            <FavoriteDagButton dagId={dag.dag_id} withText={true} />
+            <FavoriteDagButton dagId={dag.dag_id} isFavorite={dag.is_favorite} 
withText />
             <Menu.Root>
               <Menu.Trigger asChild>
                 <Button aria-label={translate("dag:header.buttons.advanced")} 
variant="outline">
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx
index 319095119bd..4f4438def45 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx
@@ -66,6 +66,7 @@ const mockDag = {
   fileloc: "/files/dags/nested_task_groups.py",
   has_import_errors: false,
   has_task_concurrency_limits: false,
+  is_favorite: false,
   is_paused: false,
   is_stale: false,
   last_expired: null,
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
index 3d3ddd902ad..0643bc8cc3f 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
@@ -72,7 +72,7 @@ export const DagCard = ({ dag }: Props) => {
             isPaused={dag.is_paused}
             withText={false}
           />
-          <FavoriteDagButton dagId={dag.dag_id} withText={false} />
+          <FavoriteDagButton dagId={dag.dag_id} isFavorite={dag.is_favorite} 
withText={false} />
           <DeleteDagButton dagDisplayName={dag.dag_display_name} 
dagId={dag.dag_id} withText={false} />
         </HStack>
       </Flex>
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
index e3f0a01ec87..ef3153876c3 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -164,8 +164,9 @@ const createColumns = (
     header: "",
   },
   {
-    accessorKey: "favorite",
-    cell: ({ row: { original } }) => <FavoriteDagButton 
dagId={original.dag_id} withText={false} />,
+    cell: ({ row: { original } }) => (
+      <FavoriteDagButton dagId={original.dag_id} 
isFavorite={original.is_favorite} withText={false} />
+    ),
     enableHiding: false,
     enableSorting: false,
     header: "",
diff --git a/airflow-core/src/airflow/ui/src/queries/useFavoriteDag.ts 
b/airflow-core/src/airflow/ui/src/queries/useFavoriteDag.ts
deleted file mode 100644
index 3f5f3aee4f4..00000000000
--- a/airflow-core/src/airflow/ui/src/queries/useFavoriteDag.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/*!
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { useQueryClient } from "@tanstack/react-query";
-
-import { useDagServiceGetDagsUiKey, useDagServiceFavoriteDag } from 
"openapi/queries";
-
-export const useFavoriteDag = () => {
-  const queryClient = useQueryClient();
-
-  const onSuccess = async () => {
-    await queryClient.invalidateQueries({ queryKey: 
[useDagServiceGetDagsUiKey] });
-  };
-
-  return useDagServiceFavoriteDag({
-    onSuccess,
-  });
-};
diff --git a/airflow-core/src/airflow/ui/src/queries/useToggleFavoriteDag.ts 
b/airflow-core/src/airflow/ui/src/queries/useToggleFavoriteDag.ts
new file mode 100644
index 00000000000..d3cfaa7cf0c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useToggleFavoriteDag.ts
@@ -0,0 +1,66 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useQueryClient } from "@tanstack/react-query";
+import { useCallback } from "react";
+
+import {
+  useDagServiceGetDagsUiKey,
+  useDagServiceFavoriteDag,
+  useDagServiceUnfavoriteDag,
+  UseDagServiceGetDagDetailsKeyFn,
+} from "openapi/queries";
+
+export const useToggleFavoriteDag = (dagId: string) => {
+  const queryClient = useQueryClient();
+
+  const onSuccess = useCallback(async () => {
+    // Invalidate the DAGs list query
+    await queryClient.invalidateQueries({
+      queryKey: [useDagServiceGetDagsUiKey, UseDagServiceGetDagDetailsKeyFn({ 
dagId }, [{ dagId }])],
+    });
+
+    // Invalidate the specific DAG details query for this DAG
+    await queryClient.invalidateQueries({
+      queryKey: UseDagServiceGetDagDetailsKeyFn({ dagId }, [{ dagId }]),
+    });
+  }, [queryClient, dagId]);
+
+  const favoriteMutation = useDagServiceFavoriteDag({
+    onSuccess,
+  });
+
+  const unfavoriteMutation = useDagServiceUnfavoriteDag({
+    onSuccess,
+  });
+
+  const toggleFavorite = useCallback(
+    (isFavorite: boolean) => {
+      const mutation = isFavorite ? unfavoriteMutation : favoriteMutation;
+
+      mutation.mutate({ dagId });
+    },
+    [dagId, favoriteMutation, unfavoriteMutation],
+  );
+
+  return {
+    error: favoriteMutation.error ?? unfavoriteMutation.error,
+    isLoading: favoriteMutation.isPending || unfavoriteMutation.isPending,
+    toggleFavorite,
+  };
+};
diff --git a/airflow-core/src/airflow/ui/src/queries/useUnfavoriteDag.ts 
b/airflow-core/src/airflow/ui/src/queries/useUnfavoriteDag.ts
deleted file mode 100644
index 008e7dbb8e3..00000000000
--- a/airflow-core/src/airflow/ui/src/queries/useUnfavoriteDag.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/*!
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { useQueryClient } from "@tanstack/react-query";
-
-import { useDagServiceGetDagsUiKey, useDagServiceUnfavoriteDag } from 
"openapi/queries";
-
-export const useUnfavoriteDag = () => {
-  const queryClient = useQueryClient();
-
-  const onSuccess = async () => {
-    await queryClient.invalidateQueries({ queryKey: 
[useDagServiceGetDagsUiKey] });
-  };
-
-  return useDagServiceUnfavoriteDag({
-    onSuccess,
-  });
-};
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
index 564a4b27348..a81eacd3fc4 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
@@ -483,6 +483,21 @@ class TestGetDags(TestDagEndpoint):
         assert body["total_entries"] == expected_total_entries
         assert sorted([dag["dag_id"] for dag in body["dags"]]) == 
sorted(expected_ids)
 
+    def test_get_dags_filter_non_favorites(self, session, test_client):
+        """Test filtering DAGs by is_favorite=false."""
+        # Mark DAG1 as favorite
+        session.add(DagFavorite(user_id="test", dag_id=DAG1_ID))
+        session.commit()
+
+        response = test_client.get("/dags", params={"is_favorite": False})
+
+        assert response.status_code == 200
+        body = response.json()
+
+        # Should return only non-favorite DAGs (DAG2)
+        assert body["total_entries"] == 1
+        assert [dag["dag_id"] for dag in body["dags"]] == [DAG2_ID]
+
     def test_get_dags_should_response_401(self, unauthenticated_test_client):
         response = unauthenticated_test_client.get("/dags")
         assert response.status_code == 401
@@ -862,6 +877,7 @@ class TestDagDetails(TestDagEndpoint):
             "template_search_path": None,
             "timetable_description": "Never, external triggers only",
             "timezone": UTC_JSON_REPR,
+            "is_favorite": False,
         }
         assert res_json == expected
 
@@ -957,6 +973,7 @@ class TestDagDetails(TestDagEndpoint):
             "template_search_path": None,
             "timetable_description": "Never, external triggers only",
             "timezone": UTC_JSON_REPR,
+            "is_favorite": False,
         }
         assert res_json == expected
 
@@ -968,6 +985,30 @@ class TestDagDetails(TestDagEndpoint):
         response = unauthorized_test_client.get(f"/dags/{DAG1_ID}/details")
         assert response.status_code == 403
 
+    def test_dag_details_includes_is_favorite_field(self, session, 
test_client):
+        """Test that DAG details include the is_favorite field."""
+        # Mark DAG2 as favorite
+        session.add(DagFavorite(user_id="test", dag_id=DAG2_ID))
+        session.commit()
+
+        response = test_client.get(f"/dags/{DAG2_ID}/details")
+        assert response.status_code == 200
+        body = response.json()
+
+        # Verify is_favorite field is present and correct
+        assert "is_favorite" in body
+        assert isinstance(body["is_favorite"], bool)
+        assert body["is_favorite"] is True
+
+        # Test with non-favorite DAG
+        response = test_client.get(f"/dags/{DAG1_ID}/details")
+        assert response.status_code == 200
+        body = response.json()
+
+        assert "is_favorite" in body
+        assert isinstance(body["is_favorite"], bool)
+        assert body["is_favorite"] is False
+
 
 class TestGetDag(TestDagEndpoint):
     """Unit tests for Get DAG."""
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
index 5ebf2221d92..d27cd3fb08d 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
@@ -26,6 +26,7 @@ from fastapi.testclient import TestClient
 from sqlalchemy.orm import Session
 
 from airflow.models import DagRun
+from airflow.models.dag_favorite import DagFavorite
 from airflow.models.hitl import HITLDetail
 from airflow.sdk.timezone import utcnow
 from airflow.utils.session import provide_session
@@ -278,3 +279,38 @@ class TestGetDagRuns(TestPublicDagEndpoint):
         body = response.json()
         assert body["total_entries"] == expected_dag_count
         assert len(body["dags"]) == expected_dag_count
+
+    def test_is_favorite_field_with_multiple_favorites(self, test_client, 
session):
+        """Test is_favorite field with multiple DAGs marked as favorites."""
+        # Mark both DAG1 and DAG2 as favorites
+        session.add(DagFavorite(user_id="test", dag_id=DAG1_ID))
+        session.add(DagFavorite(user_id="test", dag_id=DAG2_ID))
+        session.commit()
+
+        response = test_client.get("/dags")
+        assert response.status_code == 200
+        body = response.json()
+
+        # Count favorites in response
+        favorite_count = sum(1 for dag in body["dags"] if dag["is_favorite"])
+        assert favorite_count == 2
+
+        # Verify specific DAGs are marked as favorites
+        dag_favorites = {dag["dag_id"]: dag["is_favorite"] for dag in 
body["dags"]}
+        assert dag_favorites[DAG1_ID] is True
+        assert dag_favorites[DAG2_ID] is True
+
+    def test_is_favorite_field_user_specific(self, test_client, session):
+        """Test that is_favorite field is user-specific."""
+        # Mark DAG1 as favorite for a different user
+        session.add(DagFavorite(user_id="other_user", dag_id=DAG1_ID))
+        session.commit()
+
+        # Request as the test user (not other_user)
+        response = test_client.get("/dags")
+        assert response.status_code == 200
+        body = response.json()
+
+        # Verify that DAG1 is not marked as favorite for the test user
+        dag1_data = next(dag for dag in body["dags"] if dag["dag_id"] == 
DAG1_ID)
+        assert dag1_data["is_favorite"] is False
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index dc56b58aa5a..c965a5139a6 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -1302,6 +1302,7 @@ class DAGDetailsResponse(BaseModel):
     last_parsed: Annotated[datetime | None, Field(title="Last Parsed")] = None
     default_args: Annotated[dict[str, Any] | None, Field(title="Default 
Args")] = None
     owner_links: Annotated[dict[str, str] | None, Field(title="Owner Links")] 
= None
+    is_favorite: Annotated[bool | None, Field(title="Is Favorite")] = False
     file_token: Annotated[str, Field(description="Return file token.", 
title="File Token")]
     concurrency: Annotated[
         int,

Reply via email to