This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 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 250fbb22be1 Add notification UX for HITL actions (#68346)
250fbb22be1 is described below
commit 250fbb22be1145201077c9a15bf5390521680fbc
Author: hojeong park <[email protected]>
AuthorDate: Wed Jun 17 02:26:15 2026 +0900
Add notification UX for HITL actions (#68346)
* Add notification UX for HITL actions
related: #64847
* UI: Adjust HITL review list column widths
Restore changes dropped during branch sync
* UI: Simplify HITL review selection hook
Restore changes dropped during branch sync
* UI: Adjust Modal layout styling
* UI: Replace task button with link
* UI: Add horizontal scrolling to HITL review list
* UI: Add horizontal scrolling to HITL review modal header
* Add tooltip and reviewAll button on daglist
---------
Co-authored-by: pierrejeambrun <[email protected]>
---
.../airflow/ui/public/i18n/locales/en/hitl.json | 14 ++
.../src/components/HITLReview/HITLReviewDetail.tsx | 55 ++++++++
.../HITLReview/HITLReviewDetailSummary.tsx | 74 ++++++++++
.../src/components/HITLReview/HITLReviewDrawer.tsx | 65 +++++++++
.../src/components/HITLReview/HITLReviewList.tsx | 115 +++++++++++++++
.../HITLReview/HITLReviewListSection.tsx | 57 ++++++++
.../components/HITLReview/HITLReviewModal.test.tsx | 109 +++++++++++++++
.../src/components/HITLReview/HITLReviewModal.tsx | 155 +++++++++++++++++++++
.../useHITLReviewModalRouteSync.test.tsx | 78 +++++++++++
.../HITLReview/useHITLReviewModalRouteSync.ts | 58 ++++++++
.../HITLReview/useHITLReviewModalSelection.test.ts | 72 ++++++++++
.../HITLReview/useHITLReviewModalSelection.ts | 54 +++++++
.../airflow/ui/src/components/NeedsReviewBadge.tsx | 42 ++++--
.../ui/src/components/NeedsReviewButton.tsx | 141 ++++++++++++++++++-
airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx | 13 +-
.../src/airflow/ui/src/pages/Dag/Header.tsx | 2 +
.../airflow/ui/src/pages/Dag/Overview/Overview.tsx | 2 -
.../src/airflow/ui/src/pages/DagsList/DagCard.tsx | 10 +-
.../src/airflow/ui/src/pages/DagsList/DagsList.tsx | 4 +-
.../airflow/ui/src/pages/Dashboard/Stats/Stats.tsx | 4 +-
.../pages/HITLTaskInstances/HITLResponseForm.tsx | 9 +-
.../pages/HITLTaskInstances/HITLTaskInstances.tsx | 81 ++++++++++-
.../src/airflow/ui/src/pages/Run/Header.tsx | 2 +
airflow-core/src/airflow/ui/src/pages/Run/Run.tsx | 10 +-
.../airflow/ui/src/queries/useUpdateHITLDetail.ts | 11 +-
airflow-core/src/airflow/ui/src/router.tsx | 10 +-
.../ui/tests/e2e/components/HITLReviewDrawer.ts | 32 +++++
.../ui/tests/e2e/components/HITLReviewModal.ts | 32 +++++
.../src/airflow/ui/tests/e2e/fixtures/data.ts | 26 ++++
.../src/airflow/ui/tests/e2e/fixtures/pom.ts | 10 ++
.../airflow/ui/tests/e2e/pages/DagDetailPage.ts | 48 +++++++
.../src/airflow/ui/tests/e2e/pages/DagRunPage.ts | 48 +++++++
.../src/airflow/ui/tests/e2e/pages/DagsPage.ts | 21 +++
.../src/airflow/ui/tests/e2e/pages/HomePage.ts | 5 +
.../ui/tests/e2e/pages/RequiredActionsPage.ts | 29 +++-
.../airflow/ui/tests/e2e/specs/dag-detail.spec.ts | 43 ++++++
.../src/airflow/ui/tests/e2e/specs/dag-run.spec.ts | 43 ++++++
.../airflow/ui/tests/e2e/specs/dags-list.spec.ts | 42 ++++++
.../ui/tests/e2e/specs/home-dashboard.spec.ts | 15 ++
.../ui/tests/e2e/specs/requiredAction.spec.ts | 11 ++
.../src/airflow/ui/tests/e2e/utils/api/hitl.ts | 22 +++
41 files changed, 1610 insertions(+), 64 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/hitl.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/hitl.json
index a2c6e95dc08..c0fd74bbfd4 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/hitl.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/hitl.json
@@ -24,6 +24,20 @@
"success": "{{taskId}} response successful",
"title": "Human Task Instance - {{taskId}}"
},
+ "review": {
+ "detail": {
+ "selectRequiredAction": "Select a required action to see details"
+ },
+ "list": {
+ "completedRequiredActions": "Completed required actions ({{count}})",
+ "loadError": "Unable to load required actions",
+ "loadingActions": "Loading required actions...",
+ "pendingRequiredActions": "Pending required actions ({{count}})"
+ },
+ "openReviewDrawer": "Open Review Drawer",
+ "pageLimitHint": "Only the first few of actions are shown here. Use
\"$t(review.viewAll)\" to see all of them.",
+ "viewAll": "View all required actions"
+ },
"state": {
"approvalReceived": "Approval Received",
"approvalRequired": "Approval Required",
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetail.tsx
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetail.tsx
new file mode 100644
index 00000000000..b16bf656bea
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetail.tsx
@@ -0,0 +1,55 @@
+/*!
+ * 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 { Text, VStack } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import type { HITLDetail } from "openapi/requests/types.gen.ts";
+import { HITLResponseForm } from
"src/pages/HITLTaskInstances/HITLResponseForm.tsx";
+
+import { HITLReviewDetailSummary } from "./HITLReviewDetailSummary.tsx";
+
+export const HITLReviewDetail = ({
+ detail,
+ onOpenTask,
+ onResponded,
+}: {
+ readonly detail?: HITLDetail;
+ readonly onOpenTask: () => void;
+ readonly onResponded: () => void;
+}) => {
+ const { t: translate } = useTranslation("hitl");
+
+ if (detail === undefined) {
+ return (
+ <VStack py={20}>
+ <Text
color="fg.muted">{translate("review.detail.selectRequiredAction")}</Text>
+ </VStack>
+ );
+ }
+
+ const ti = detail.task_instance;
+
+ return (
+ <VStack alignItems="stretch" gap={4}>
+ <HITLReviewDetailSummary detail={detail} onOpenTask={onOpenTask} />
+
+ <HITLResponseForm hitlDetail={detail} key={ti.id} namespace={ti.id}
onResponded={onResponded} />
+ </VStack>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetailSummary.tsx
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetailSummary.tsx
new file mode 100644
index 00000000000..f6d02117bc5
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetailSummary.tsx
@@ -0,0 +1,74 @@
+/*!
+ * 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 { Table, Text } from "@chakra-ui/react";
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+
+import type { HITLDetail } from "openapi/requests/types.gen.ts";
+import Time from "src/components/Time.tsx";
+import { RouterLink } from "src/components/ui/RouterLink.tsx";
+import { getRelativeTime } from "src/utils/datetimeUtils.ts";
+import { getTaskInstanceLink } from "src/utils/links.ts";
+
+const HITLReviewRow = ({ label, value }: { readonly label: string; readonly
value: ReactNode }) => (
+ <Table.Row>
+ <Table.Cell w="30%">{label}</Table.Cell>
+ <Table.Cell>{value}</Table.Cell>
+ </Table.Row>
+);
+
+export const HITLReviewDetailSummary = ({
+ detail,
+ onOpenTask,
+}: {
+ readonly detail: HITLDetail;
+ readonly onOpenTask: () => void;
+}) => {
+ const { t: translate } = useTranslation(["hitl", "common"]);
+ const ti = detail.task_instance;
+ const mappedIndex = ti.rendered_map_index ?? (ti.map_index >= 0 ?
ti.map_index : undefined);
+
+ return (
+ <Table.Root>
+ <Table.Body>
+ <HITLReviewRow label={translate("common:dagId")} value={ti.dag_id} />
+ <HITLReviewRow label={translate("common:dagRunId")}
value={ti.dag_run_id} />
+ <HITLReviewRow label={translate("common:mapIndex")} value={mappedIndex
?? "-"} />
+ <HITLReviewRow
+ label={translate("common:taskId")}
+ value={
+ <RouterLink onClick={onOpenTask}
to={`${getTaskInstanceLink(ti)}/required_actions`}>
+ {ti.task_id}
+ </RouterLink>
+ }
+ />
+ <HITLReviewRow
+ label={translate("common:table.createdAt")}
+ value={
+ <Text>
+ <Time datetime={detail.created_at} />
+ {` (${getRelativeTime(detail.created_at)})`}
+ </Text>
+ }
+ />
+ <HITLReviewRow label={translate("common:tryNumber")}
value={ti.try_number} />
+ </Table.Body>
+ </Table.Root>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDrawer.tsx
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDrawer.tsx
new file mode 100644
index 00000000000..df0b450d128
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDrawer.tsx
@@ -0,0 +1,65 @@
+/*!
+ * 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 { CloseButton, Drawer, Portal } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import type { HITLDetail } from "openapi/requests/types.gen.ts";
+
+import { HITLReviewDetail } from "./HITLReviewDetail.tsx";
+
+type HITLReviewDrawerProps = {
+ readonly detail?: HITLDetail;
+ readonly onClose: () => void;
+ readonly open: boolean;
+};
+
+export const HITLReviewDrawer = ({ detail, onClose, open }:
HITLReviewDrawerProps) => {
+ const { t: translate } = useTranslation("hitl");
+
+ return (
+ <Drawer.Root
+ lazyMount
+ onOpenChange={(event) => {
+ if (!event.open) {
+ onClose();
+ }
+ }}
+ open={open}
+ size="xl"
+ unmountOnExit
+ >
+ <Portal>
+ <Drawer.Backdrop />
+ <Drawer.Positioner>
+ <Drawer.Content>
+ <Drawer.Header>
+ <Drawer.Title>{translate("requiredAction_other")}</Drawer.Title>
+ </Drawer.Header>
+ <Drawer.CloseTrigger asChild>
+ <CloseButton />
+ </Drawer.CloseTrigger>
+ <Drawer.Body>
+ <HITLReviewDetail detail={detail} onOpenTask={onClose}
onResponded={onClose} />
+ </Drawer.Body>
+ </Drawer.Content>
+ </Drawer.Positioner>
+ </Portal>
+ </Drawer.Root>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewList.tsx
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewList.tsx
new file mode 100644
index 00000000000..9f33d0600d4
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewList.tsx
@@ -0,0 +1,115 @@
+/*!
+ * 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 { Table } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import type { HITLDetail } from "openapi/requests/types.gen.ts";
+import Time from "src/components/Time.tsx";
+
+const TableColumnHeader = ({ children, width }: { readonly children: string;
readonly width?: string }) => (
+ <Table.ColumnHeader w={width}>{children}</Table.ColumnHeader>
+);
+
+const HITL_GROUP_COLORS = ["green.solid", "purple.solid"] as const;
+
+const getHitlGroupColor = (details: Array<HITLDetail>, index: number) => {
+ let groupIndex = 0;
+
+ for (let currentIndex = 0; currentIndex <= index; currentIndex += 1) {
+ const detail = details[currentIndex];
+ const previous = details[currentIndex - 1];
+
+ if (
+ detail !== undefined &&
+ previous !== undefined &&
+ detail.task_instance.dag_id !== previous.task_instance.dag_id
+ ) {
+ groupIndex += 1;
+ }
+ }
+
+ return HITL_GROUP_COLORS[groupIndex % HITL_GROUP_COLORS.length] ??
HITL_GROUP_COLORS[0];
+};
+
+export const HITLReviewList = ({
+ details,
+ onSelect,
+ selectedDetail,
+}: {
+ readonly details: Array<HITLDetail>;
+ readonly onSelect: (selection: HITLDetail) => void;
+ readonly selectedDetail?: HITLDetail;
+}) => {
+ const { t: translate } = useTranslation(["hitl", "common"]);
+
+ return (
+ <Table.Root minW="max-content">
+ <Table.Header>
+ <Table.Row>
+ <TableColumnHeader
width="30%">{translate("common:dagId")}</TableColumnHeader>
+ <TableColumnHeader
width="170px">{translate("common:dagRun_one")}</TableColumnHeader>
+ <TableColumnHeader
width="90px">{translate("common:mapIndex")}</TableColumnHeader>
+ <TableColumnHeader>{translate("common:taskId")}</TableColumnHeader>
+ <TableColumnHeader
width="170px">{translate("common:table.createdAt")}</TableColumnHeader>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
+ {details.length === 0
+ ? null
+ : details.map((detail, index) => {
+ const isSelected = selectedDetail?.task_instance.id ===
detail.task_instance.id;
+ const ti = detail.task_instance;
+
+ return (
+ <Table.Row
+ _hover={{ bg: isSelected ? "bg.muted" : "bg.subtle" }}
+ aria-pressed={isSelected}
+ bg={isSelected ? "bg.muted" : undefined}
+ cursor="pointer"
+ key={detail.task_instance.id}
+ onClick={() => onSelect(detail)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onSelect(detail);
+ }
+ }}
+ py={2}
+ tabIndex={0}
+ >
+ <Table.Cell borderLeftColor={getHitlGroupColor(details,
index)} borderLeftWidth={3}>
+ {ti.dag_id}
+ </Table.Cell>
+ <Table.Cell>
+ <Time datetime={ti.run_after} />
+ </Table.Cell>
+ <Table.Cell>
+ {ti.rendered_map_index ?? (ti.map_index === -1 ? undefined
: String(ti.map_index))}
+ </Table.Cell>
+ <Table.Cell>{ti.task_id}</Table.Cell>
+ <Table.Cell>
+ <Time datetime={detail.created_at} />
+ </Table.Cell>
+ </Table.Row>
+ );
+ })}
+ </Table.Body>
+ </Table.Root>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewListSection.tsx
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewListSection.tsx
new file mode 100644
index 00000000000..c3273727423
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewListSection.tsx
@@ -0,0 +1,57 @@
+/*!
+ * 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 { Heading, Text, VStack } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import type { HITLDetail } from "openapi/requests/types.gen.ts";
+
+import { HITLReviewList } from "./HITLReviewList.tsx";
+
+export const HITLReviewListSection = ({
+ details,
+ heading,
+ isError = false,
+ isLoading = false,
+ onSelect,
+ selectedDetail,
+}: {
+ readonly details?: Array<HITLDetail>;
+ readonly heading: string;
+ readonly isError?: boolean;
+ readonly isLoading?: boolean;
+ readonly onSelect: (selection: HITLDetail) => void;
+ readonly selectedDetail?: HITLDetail;
+}) => {
+ const { t: translate } = useTranslation("hitl");
+
+ return (
+ <VStack alignItems="stretch">
+ <Heading size="md">{heading}</Heading>
+ <VStack alignItems="stretch" borderRadius="md" borderWidth={1}
overflowX="auto">
+ {isLoading ? (
+ <Text
color="fg.muted">{translate("review.list.loadingActions")}</Text>
+ ) : isError ? (
+ <Text color="fg.error">{translate("review.list.loadError")}</Text>
+ ) : (
+ <HITLReviewList details={details ?? []} onSelect={onSelect}
selectedDetail={selectedDetail} />
+ )}
+ </VStack>
+ </VStack>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewModal.test.tsx
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewModal.test.tsx
new file mode 100644
index 00000000000..ed10180ad41
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewModal.test.tsx
@@ -0,0 +1,109 @@
+/*!
+ * 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 "@testing-library/jest-dom/vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { HITLDetail } from "openapi/requests/types.gen.ts";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { HITLReviewModal } from "./HITLReviewModal";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ // eslint-disable-next-line id-length
+ t: (key: string) => key,
+ }),
+}));
+
+vi.mock("src/components/HITLReview/HITLReviewDetail.tsx", () => ({
+ HITLReviewDetail: () => null,
+}));
+
+const mockHitl = {
+ created_at: "2024-01-01T00:00:00Z",
+ options: ["Approve", "Reject"],
+ subject: "Test subject",
+ task_instance: {
+ dag_id: "test_dag",
+ dag_run_id: "test_run",
+ id: "pending",
+ map_index: -1,
+ rendered_map_index: null,
+ run_after: "2024-01-01T00:00:00Z",
+ state: "awaiting_input",
+ task_id: "task-pending",
+ try_number: 1,
+ },
+} as HITLDetail;
+
+const completedHitl = {
+ ...mockHitl,
+ task_instance: {
+ ...mockHitl.task_instance,
+ id: "completed",
+ task_id: "task-completed",
+ },
+};
+
+const renderModal = ({ withCompleted = false }: { readonly withCompleted?:
boolean } = {}) =>
+ render(
+ <HITLReviewModal
+ completedHitl={withCompleted ? { data: [completedHitl] } : undefined}
+ onClose={vi.fn()}
+ open
+ pendingHitl={{ data: [mockHitl] }}
+ />,
+ { wrapper: Wrapper },
+ );
+
+describe("HITLReviewModal", () => {
+ it("renders the required actions dialog", () => {
+ renderModal();
+
+ expect(screen.getByRole("dialog", { name: "requiredAction_other"
})).toBeInTheDocument();
+ });
+
+ it("does not render the completed filter when completed HITL data is not
provided", () => {
+ renderModal();
+
+ expect(screen.queryByRole("button", { name: "filters.response.all"
})).toBeNull();
+ });
+
+ it("renders the completed filter when completed HITL data is provided", ()
=> {
+ renderModal({ withCompleted: true });
+
+ expect(screen.getByRole("button", { name: "filters.response.all"
})).toBeInTheDocument();
+ });
+
+ it("shows only pending HITL rows by default", () => {
+ renderModal({ withCompleted: true });
+
+ expect(screen.getByText("task-pending")).toBeInTheDocument();
+ expect(screen.queryByText("task-completed")).toBeNull();
+ });
+
+ it("shows completed HITL rows when switching to all required actions", () =>
{
+ renderModal({ withCompleted: true });
+
+ fireEvent.click(screen.getByRole("button", { name: "filters.response.all"
}));
+
+ expect(screen.getByText("task-completed")).toBeInTheDocument();
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewModal.tsx
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewModal.tsx
new file mode 100644
index 00000000000..6da3e60f71d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewModal.tsx
@@ -0,0 +1,155 @@
+/*!
+ * 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 { Box, HStack, Icon, VStack } from "@chakra-ui/react";
+import type { ReactNode } from "react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { FiInfo } from "react-icons/fi";
+
+import type { HITLDetail } from "openapi/requests/types.gen.ts";
+import { HITLReviewDetail } from
"src/components/HITLReview/HITLReviewDetail.tsx";
+import { Tooltip } from "src/components/ui";
+import { ButtonGroupToggle } from "src/components/ui/ButtonGroupToggle";
+import { Dialog } from "src/components/ui/Dialog";
+
+import { HITLReviewListSection } from "./HITLReviewListSection.tsx";
+import { useHITLReviewModalSelection } from "./useHITLReviewModalSelection.ts";
+
+enum HITLReviewFilterMode {
+ ALL = "all",
+ PENDING = "pending",
+}
+
+type HITLReviewListState = {
+ readonly data: Array<HITLDetail>;
+ readonly isError?: boolean;
+ readonly isLoading?: boolean;
+ readonly total?: number;
+};
+
+const HITL_REVIEW_FILTER_OPTIONS: Array<{ labelKey: string; value:
HITLReviewFilterMode }> = [
+ { labelKey: "filters.response.pending", value: HITLReviewFilterMode.PENDING
},
+ { labelKey: "filters.response.all", value: HITLReviewFilterMode.ALL },
+];
+
+export const HITLReviewModal = ({
+ completedHitl,
+ headerAction,
+ onClose,
+ open,
+ pendingHitl,
+}: {
+ readonly completedHitl?: HITLReviewListState;
+ readonly headerAction?: ReactNode;
+ readonly onClose: () => void;
+ readonly open: boolean;
+ readonly pendingHitl: HITLReviewListState;
+}) => {
+ const { t: translate } = useTranslation("hitl");
+ const [selectedFilter, setSelectedFilter] =
useState<HITLReviewFilterMode>(HITLReviewFilterMode.PENDING);
+ const shouldShowCompletedHitl = completedHitl !== undefined;
+ const visibleHitls =
+ shouldShowCompletedHitl && selectedFilter === HITLReviewFilterMode.ALL
+ ? [...pendingHitl.data, ...completedHitl.data]
+ : pendingHitl.data;
+
+ const { onNext, onSelect, selectedDetail } = useHITLReviewModalSelection({
+ hitlDetails: visibleHitls,
+ });
+ const handleClose = () => {
+ setSelectedFilter(HITLReviewFilterMode.PENDING);
+ onClose();
+ };
+
+ return (
+ <Dialog.Root
+ lazyMount
+ onOpenChange={(event) => {
+ if (!event.open) {
+ handleClose();
+ }
+ }}
+ open={open}
+ scrollBehavior="inside"
+ unmountOnExit
+ >
+ <Dialog.Content maxW="1440px" p={4}>
+ <Dialog.Header>
+ <HStack justifyContent="space-between" overflowX="auto" width="100%">
+ <HStack flexShrink={0} gap={1}>
+ <Dialog.Title>{translate("requiredAction_other")}</Dialog.Title>
+ <Tooltip content={translate("review.pageLimitHint")}>
+ <Icon color="fg.muted">
+ <FiInfo />
+ </Icon>
+ </Tooltip>
+ </HStack>
+ <HStack gap={3}>
+ {headerAction}
+ {shouldShowCompletedHitl ? (
+ <ButtonGroupToggle<HITLReviewFilterMode>
+ onChange={setSelectedFilter}
+ options={HITL_REVIEW_FILTER_OPTIONS.map((option) => ({
+ label: translate(option.labelKey),
+ value: option.value,
+ }))}
+ value={selectedFilter}
+ />
+ ) : undefined}
+ </HStack>
+ </HStack>
+ </Dialog.Header>
+ <Dialog.CloseTrigger />
+ <Dialog.Body>
+ <HStack alignItems="stretch" flexDirection={{ base: "column", lg:
"row" }} gap={6}>
+ <Box flex="2">
+ <VStack alignItems="stretch" gap={4}>
+ <HITLReviewListSection
+ details={pendingHitl.data}
+ heading={translate("review.list.pendingRequiredActions", {
+ count: pendingHitl.total ?? pendingHitl.data.length,
+ })}
+ isError={pendingHitl.isError}
+ isLoading={pendingHitl.isLoading}
+ onSelect={onSelect}
+ selectedDetail={selectedDetail}
+ />
+ {shouldShowCompletedHitl && selectedFilter ===
HITLReviewFilterMode.ALL ? (
+ <HITLReviewListSection
+ details={completedHitl.data}
+ heading={translate("review.list.completedRequiredActions",
{
+ count: completedHitl.total ?? completedHitl.data.length,
+ })}
+ isError={completedHitl.isError}
+ isLoading={completedHitl.isLoading}
+ onSelect={onSelect}
+ selectedDetail={selectedDetail}
+ />
+ ) : null}
+ </VStack>
+ </Box>
+ <Box borderRadius="md" borderWidth={1} flex="1" mt={8} p={3}>
+ <HITLReviewDetail detail={selectedDetail}
onOpenTask={handleClose} onResponded={onNext} />
+ </Box>
+ </HStack>
+ </Dialog.Body>
+ </Dialog.Content>
+ </Dialog.Root>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalRouteSync.test.tsx
b/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalRouteSync.test.tsx
new file mode 100644
index 00000000000..a557a4b2305
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalRouteSync.test.tsx
@@ -0,0 +1,78 @@
+/*!
+ * 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 { act, renderHook } from "@testing-library/react";
+import type { PropsWithChildren } from "react";
+import type * as ReactRouterDom from "react-router-dom";
+import { MemoryRouter } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { useHITLReviewModalRouteSync } from "./useHITLReviewModalRouteSync";
+
+const navigate = vi.hoisted(() => vi.fn());
+
+vi.mock("react-router-dom", async (importOriginal) => {
+ const actual = await importOriginal<typeof ReactRouterDom>();
+
+ return {
+ ...actual,
+ useNavigate: () => navigate,
+ };
+});
+
+const createWrapper =
+ (initialEntry = "/") =>
+ ({ children }: PropsWithChildren) => (
+ <BaseWrapper>
+ <MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>
+ </BaseWrapper>
+ );
+
+beforeEach(() => {
+ navigate.mockClear();
+});
+
+describe("useHITLReviewModalRouteSync", () => {
+ it("opens the modal when the route points to required actions", () => {
+ const onClose = vi.fn();
+ const onOpen = vi.fn();
+
+ renderHook(() => useHITLReviewModalRouteSync({ onClose, onOpen }), {
+ wrapper:
createWrapper("/dags/example_dag/tasks/example_task/required_actions"),
+ });
+
+ expect(onOpen).toHaveBeenCalledTimes(1);
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("removes the required actions route when closing the HITL review", () => {
+ const onClose = vi.fn();
+ const { result } = renderHook(() => useHITLReviewModalRouteSync({ onClose,
onOpen: vi.fn() }), {
+ wrapper:
createWrapper("/dags/example_dag/tasks/example_task/required_actions"),
+ });
+
+ act(() => {
+ result.current.onCloseHITLReview();
+ });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+
expect(navigate).toHaveBeenCalledWith("/dags/example_dag/tasks/example_task", {
replace: true });
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalRouteSync.ts
b/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalRouteSync.ts
new file mode 100644
index 00000000000..a4778e1ef6a
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalRouteSync.ts
@@ -0,0 +1,58 @@
+/*!
+ * 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 { useEffect, useRef } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+
+export const useHITLReviewModalRouteSync = ({
+ onClose,
+ onOpen,
+}: {
+ readonly onClose: () => void;
+ readonly onOpen: () => void;
+}) => {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const wasOpenedFromRouteRef = useRef(false);
+ const isHITLReviewRoute = location.pathname.endsWith("/required_actions");
+
+ useEffect(() => {
+ if (isHITLReviewRoute) {
+ wasOpenedFromRouteRef.current = true;
+ onOpen();
+ } else if (wasOpenedFromRouteRef.current) {
+ wasOpenedFromRouteRef.current = false;
+ onClose();
+ }
+ }, [isHITLReviewRoute, onClose, onOpen]);
+
+ const onCloseHITLReview = () => {
+ wasOpenedFromRouteRef.current = false;
+ onClose();
+
+ if (isHITLReviewRoute) {
+ const redirectPath = location.pathname.replace(/\/required_actions$/u,
"") || "/";
+
+ void Promise.resolve(navigate(redirectPath, { replace: true }));
+ }
+ };
+
+ return {
+ onCloseHITLReview,
+ };
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalSelection.test.ts
b/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalSelection.test.ts
new file mode 100644
index 00000000000..cb544c7377c
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalSelection.test.ts
@@ -0,0 +1,72 @@
+/*!
+ * 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 { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import type { HITLDetail } from "openapi/requests/types.gen.ts";
+
+import { useHITLReviewModalSelection } from "./useHITLReviewModalSelection";
+
+const hitl = (id: string) =>
+ ({
+ task_instance: { id },
+ }) as HITLDetail;
+
+describe("useHITLReviewModalSelection", () => {
+ it("does not select a HITL detail when the list is empty", () => {
+ const { result } = renderHook(() => useHITLReviewModalSelection({
hitlDetails: [] }));
+
+ expect(result.current.selectedDetail).toBeUndefined();
+ });
+
+ it("selects the first HITL detail by default", () => {
+ const details = [hitl("first"), hitl("second")];
+
+ const { result } = renderHook(() => useHITLReviewModalSelection({
hitlDetails: details }));
+
+ expect(result.current.selectedDetail).toBe(details[0]);
+ });
+
+ it("moves selection forward", () => {
+ const details = [hitl("first"), hitl("second")];
+
+ const { result } = renderHook(() => useHITLReviewModalSelection({
hitlDetails: details }));
+
+ act(() => result.current.onNext());
+ expect(result.current.selectedDetail).toBe(details[1]);
+ });
+
+ it("falls back to the first HITL detail when the selected key is no longer
visible", () => {
+ const first = hitl("first");
+ const second = hitl("second");
+ const details = [first, second];
+
+ const { rerender, result } = renderHook(
+ ({ hitlDetails }: { readonly hitlDetails: Array<HITLDetail> }) =>
+ useHITLReviewModalSelection({ hitlDetails }),
+ { initialProps: { hitlDetails: details } },
+ );
+
+ act(() => result.current.onNext());
+ expect(result.current.selectedDetail).toBe(second);
+
+ rerender({ hitlDetails: [first] });
+ expect(result.current.selectedDetail).toBe(first);
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalSelection.ts
b/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalSelection.ts
new file mode 100644
index 00000000000..11e704407e9
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/HITLReview/useHITLReviewModalSelection.ts
@@ -0,0 +1,54 @@
+/*!
+ * 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 { useState } from "react";
+
+import type { HITLDetail } from "openapi/requests/types.gen.ts";
+
+export const useHITLReviewModalSelection = ({ hitlDetails }: { readonly
hitlDetails: Array<HITLDetail> }) => {
+ const [selectedHITLDetailKey, setSelectedHITLDetailKey] = useState<string |
undefined>(undefined);
+
+ const selectedIndex = (() => {
+ if (hitlDetails.length === 0) {
+ return -1;
+ }
+
+ const selectedIndexFromKey = hitlDetails.findIndex(
+ (hitlDetail) => hitlDetail.task_instance.id === selectedHITLDetailKey,
+ );
+
+ return selectedIndexFromKey === -1 ? 0 : selectedIndexFromKey;
+ })();
+ const isSelected = selectedIndex !== -1;
+
+ const hasNext = isSelected && selectedIndex < hitlDetails.length - 1;
+
+ const onSelect = (hitl?: HITLDetail) =>
setSelectedHITLDetailKey(hitl?.task_instance.id);
+
+ const onNext = () => {
+ if (hasNext) {
+ onSelect(hitlDetails[selectedIndex + 1]);
+ }
+ };
+
+ return {
+ onNext,
+ onSelect,
+ selectedDetail: isSelected ? hitlDetails[selectedIndex] : undefined,
+ };
+};
diff --git a/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx
b/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx
index e270a32e455..4302a54b2b4 100644
--- a/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx
+++ b/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx
@@ -16,38 +16,50 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { Button, useDisclosure } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { LuUserRoundPen } from "react-icons/lu";
-import { Link as RouterLink } from "react-router-dom";
+import { Link } from "react-router-dom";
import type { HITLDetail } from "openapi/requests/types.gen";
+import { HITLReviewModal } from
"src/components/HITLReview/HITLReviewModal.tsx";
import { StateBadge } from "src/components/StateBadge";
import { Tooltip } from "src/components/ui";
-import { SearchParamsKeys } from "src/constants/searchParams";
type Props = {
- readonly dagId: string;
readonly pendingActions: Array<HITLDetail>;
};
-export const NeedsReviewBadge = ({ dagId, pendingActions }: Props) => {
+export const NeedsReviewBadge = ({ pendingActions }: Props) => {
const { t: translate } = useTranslation("hitl");
+ const { onClose, onOpen, open } = useDisclosure();
if (pendingActions.length === 0) {
return undefined;
}
return (
- <Tooltip content={translate("requiredActionCount", { count:
pendingActions.length })}>
- <RouterLink
- data-testid="needs-review-badge"
-
to={`/dags/${dagId}/required_actions?${SearchParamsKeys.RESPONSE_RECEIVED}=false`}
- >
- <StateBadge colorPalette="awaiting_input" fontSize="md"
variant="solid">
- <LuUserRoundPen />
- {pendingActions.length}
- </StateBadge>
- </RouterLink>
- </Tooltip>
+ <>
+ <Tooltip content={translate("requiredActionCount", { count:
pendingActions.length })}>
+ <Button data-testid="needs-review-badge" onClick={onOpen}
variant="plain">
+ <StateBadge colorPalette="awaiting_input" fontSize="md"
variant="solid">
+ <LuUserRoundPen />
+ {pendingActions.length}
+ </StateBadge>
+ </Button>
+ </Tooltip>
+ <HITLReviewModal
+ headerAction={
+ <Button asChild size="sm" variant="outline">
+ <Link onClick={onClose}
to="/required_actions?response_received=false">
+ {translate("review.viewAll")}
+ </Link>
+ </Button>
+ }
+ onClose={onClose}
+ open={open}
+ pendingHitl={{ data: pendingActions, isError: false, isLoading: false
}}
+ />
+ </>
);
};
diff --git a/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx
b/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx
index ade24b26f78..a48252b2d8f 100644
--- a/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx
@@ -16,16 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box } from "@chakra-ui/react";
+import { Box, Button, useDisclosure } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { LuUserRoundPen } from "react-icons/lu";
+import { Link } from "react-router-dom";
import { useTaskInstanceServiceGetHitlDetails } from "openapi/queries";
+import { HITLReviewModal } from
"src/components/HITLReview/HITLReviewModal.tsx";
+import { useHITLReviewModalRouteSync } from
"src/components/HITLReview/useHITLReviewModalRouteSync.ts";
import { useAutoRefresh } from "src/utils/query";
import { StatsCard } from "./StatsCard";
-export const NeedsReviewButton = ({
+const usePendingHitl = ({
dagId,
runId,
taskId,
@@ -36,10 +39,15 @@ export const NeedsReviewButton = ({
}) => {
const refetchInterval = useAutoRefresh({ checkPendingRuns: true, dagId });
- const { data: hitlStatsData, isLoading } =
useTaskInstanceServiceGetHitlDetails(
+ const {
+ data: pendingHitlData,
+ isError,
+ isLoading,
+ } = useTaskInstanceServiceGetHitlDetails(
{
dagId: dagId ?? "~",
dagRunId: runId ?? "~",
+ orderBy: ["dag_id", "run_after", "created_at", "task_display_name"],
responseReceived: false,
state: ["deferred", "awaiting_input"],
taskId,
@@ -50,7 +58,53 @@ export const NeedsReviewButton = ({
},
);
- const hitlTIsCount = hitlStatsData?.hitl_details.length ?? 0;
+ return {
+ isError,
+ isLoading,
+ pendingHitlData,
+ };
+};
+
+const useCompletedHitl = ({
+ dagId,
+ enabled,
+ runId,
+}: {
+ readonly dagId?: string;
+ readonly enabled: boolean;
+ readonly runId?: string;
+}) => {
+ const {
+ data: completedHitlData,
+ isError,
+ isLoading,
+ } = useTaskInstanceServiceGetHitlDetails(
+ {
+ dagId: dagId ?? "~",
+ dagRunId: runId ?? "~",
+ orderBy: ["dag_id", "run_after", "created_at", "task_display_name"],
+ responseReceived: true,
+ },
+ undefined,
+ {
+ enabled,
+ },
+ );
+
+ return { completedHitlData, isError, isLoading };
+};
+
+const NeedsReviewButtonCard = ({
+ hitlTIsCount,
+ isLoading,
+ link,
+ onClick,
+}: {
+ readonly hitlTIsCount: number;
+ readonly isLoading: boolean;
+ readonly link?: string;
+ readonly onClick?: () => void;
+}) => {
const { i18n, t: translate } = useTranslation("hitl");
const isRTL = i18n.dir() === "rtl";
@@ -64,8 +118,85 @@ export const NeedsReviewButton = ({
isLoading={isLoading}
isRTL={isRTL}
label={translate("requiredAction_other")}
- link="required_actions?response_received=false"
+ link={link}
+ onClick={onClick}
/>
</Box>
) : undefined;
};
+
+export const NeedsReviewButton = ({
+ dagId,
+ runId,
+ taskId,
+}: {
+ readonly dagId?: string;
+ readonly runId?: string;
+ readonly taskId?: string;
+}) => {
+ const { isLoading, pendingHitlData } = usePendingHitl({ dagId, runId, taskId
});
+ const hitlTIsCount = pendingHitlData?.total_entries ?? 0;
+
+ return (
+ <NeedsReviewButtonCard
+ hitlTIsCount={hitlTIsCount}
+ isLoading={isLoading}
+ link="required_actions?response_received=false"
+ />
+ );
+};
+
+export const NeedsReviewButtonWithModal = ({
+ dagId,
+ runId,
+}: {
+ readonly dagId?: string;
+ readonly runId?: string;
+}) => {
+ const { onClose, onOpen, open } = useDisclosure();
+ const { onCloseHITLReview } = useHITLReviewModalRouteSync({
+ onClose,
+ onOpen,
+ });
+ const { isError: pendingHitlIsError, isLoading, pendingHitlData } =
usePendingHitl({ dagId, runId });
+ const {
+ completedHitlData,
+ isError: completedHitlIsError,
+ isLoading: isLoadingCompletedHitl,
+ } = useCompletedHitl({
+ dagId,
+ enabled: open,
+ runId,
+ });
+ const { t: translate } = useTranslation("hitl");
+ const hitlTIsCount = pendingHitlData?.total_entries ?? 0;
+
+ return (
+ <>
+ <NeedsReviewButtonCard hitlTIsCount={hitlTIsCount} isLoading={isLoading}
onClick={onOpen} />
+ <HITLReviewModal
+ completedHitl={{
+ data: completedHitlData?.hitl_details ?? [],
+ isError: completedHitlIsError,
+ isLoading: isLoadingCompletedHitl,
+ total: completedHitlData?.total_entries,
+ }}
+ headerAction={
+ <Button asChild size="sm" variant="outline">
+ <Link onClick={onCloseHITLReview}
to="/required_actions?response_received=false">
+ {translate("review.viewAll")}
+ </Link>
+ </Button>
+ }
+ onClose={onCloseHITLReview}
+ open={open}
+ pendingHitl={{
+ data: pendingHitlData?.hitl_details ?? [],
+ isError: pendingHitlIsError,
+ isLoading,
+ total: pendingHitlData?.total_entries,
+ }}
+ />
+ </>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
index ff1bc4b9495..2d1f3bd8f6a 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
@@ -19,7 +19,7 @@
import { ReactFlowProvider } from "@xyflow/react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import { FiBarChart, FiCode, FiUser, FiCalendar } from "react-icons/fi";
+import { FiBarChart, FiCode, FiCalendar } from "react-icons/fi";
import { LuChartColumn } from "react-icons/lu";
import { MdDetails, MdOutlineEventNote } from "react-icons/md";
import { RiArrowGoBackFill } from "react-icons/ri";
@@ -29,7 +29,6 @@ import { useDagServiceGetDagDetails,
useDagServiceGetLatestRunInfo } from "opena
import { ApiError } from "openapi/requests/core/ApiError";
import { TaskIcon } from "src/assets/TaskIcon";
import { usePluginTabs } from "src/hooks/usePluginTabs";
-import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
import { useRefreshOnNewDagRuns } from "src/queries/useRefreshOnNewDagRuns";
import { isStatePending, useAutoRefresh, useDocumentTitle } from "src/utils";
@@ -49,7 +48,6 @@ export const Dag = () => {
{ icon: <FiBarChart />, label: translate("tabs.runs"), value: "runs" },
{ icon: <TaskIcon />, label: translate("tabs.tasks"), value: "tasks" },
{ icon: <FiCalendar />, label: translate("tabs.calendar"), value:
"calendar" },
- { icon: <FiUser />, label: translate("tabs.requiredActions"), value:
"required_actions" },
{ icon: <RiArrowGoBackFill />, label: translate("tabs.backfills"), value:
"backfills" },
{ icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value:
"events" },
{ icon: <FiCode />, label: translate("tabs.code"), value: "code" },
@@ -123,14 +121,7 @@ export const Dag = () => {
},
);
- const { tabs: processedTabs } = useRequiredActionTabs({ dagId }, tabs, {
- refetchInterval:
- (dag?.active_runs_count ?? 0) > 0 || (latestRun &&
isStatePending(latestRun.state))
- ? refetchInterval
- : false,
- });
-
- const displayTabs = processedTabs.filter((tab) => {
+ const displayTabs = tabs.filter((tab) => {
if (dag?.timetable_summary === null && tab.value === "backfills") {
return false;
}
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 9e3d915b079..419db668c81 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
@@ -29,6 +29,7 @@ import DagRunInfo from "src/components/DagRunInfo";
import { DagVersion } from "src/components/DagVersion";
import DisplayMarkdownButton from "src/components/DisplayMarkdownButton";
import { HeaderCard } from "src/components/HeaderCard";
+import { NeedsReviewButtonWithModal } from "src/components/NeedsReviewButton";
import { TogglePause } from "src/components/TogglePause";
import { RouterLink } from "src/components/ui";
@@ -131,6 +132,7 @@ export const Header = ({
dag === undefined ? undefined : (
<>
<DeadlineAlertsBadge dagId={dag.dag_id} />
+ <NeedsReviewButtonWithModal dagId={dag.dag_id} />
{dag.doc_md === null ? undefined : (
<DisplayMarkdownButton
header={translate("dagDetails.documentation")}
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx
index 0d81a888031..d66be6299c4 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx
@@ -30,7 +30,6 @@ import {
} from "openapi/queries";
import { AssetEvents } from "src/components/Assets/AssetEvents";
import { DurationChart } from "src/components/DurationChart";
-import { NeedsReviewButton } from "src/components/NeedsReviewButton";
import TimeRangeSelector from "src/components/TimeRangeSelector";
import { TrendCountButton } from "src/components/TrendCountButton";
import { dagRunsLimitKey } from "src/constants/localStorage";
@@ -87,7 +86,6 @@ export const Overview = () => {
return (
<Box m={4} spaceY={4}>
- <NeedsReviewButton dagId={dagId} />
<Box my={2}>
<TimeRangeSelector
defaultValue={defaultHour}
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 aefe80c5aaa..9eef74269d6 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
@@ -45,7 +45,13 @@ export const DagCard = ({ dag }: Props) => {
const refetchInterval = useAutoRefresh({});
return (
- <Box borderColor="border.emphasized" borderRadius={8} borderWidth={1}
overflow="hidden">
+ <Box
+ borderColor="border.emphasized"
+ borderRadius={8}
+ borderWidth={1}
+ data-testid="dag-card"
+ overflow="hidden"
+ >
<Flex alignItems="center" bg="bg.muted" justifyContent="space-between"
px={3} py={1}>
<HStack>
<Tooltip content={dag.description}
disabled={!Boolean(dag.description)}>
@@ -56,7 +62,7 @@ export const DagCard = ({ dag }: Props) => {
<DagTags tags={dag.tags} />
</HStack>
<HStack gap={1}>
- <NeedsReviewBadge dagId={dag.dag_id}
pendingActions={dag.pending_actions} />
+ <NeedsReviewBadge pendingActions={dag.pending_actions} />
<TogglePause dagDisplayName={dag.dag_display_name}
dagId={dag.dag_id} isPaused={dag.is_paused} />
<TriggerDAGButton
allowedRunTypes={dag.allowed_run_types}
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 0e26e363c66..eaedd94406a 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -133,9 +133,7 @@ const createColumns = (
},
{
accessorKey: "pending_actions",
- cell: ({ row: { original: dag } }) => (
- <NeedsReviewBadge dagId={dag.dag_id}
pendingActions={dag.pending_actions} />
- ),
+ cell: ({ row: { original: dag } }) => <NeedsReviewBadge
pendingActions={dag.pending_actions} />,
enableSorting: false,
header: "",
},
diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
index 6fb64d2659d..25545dfac50 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
@@ -21,7 +21,7 @@ import { useTranslation } from "react-i18next";
import { FiClipboard, FiZap } from "react-icons/fi";
import { useDashboardServiceDagStats } from "openapi/queries";
-import { NeedsReviewButton } from "src/components/NeedsReviewButton";
+import { NeedsReviewButtonWithModal } from "src/components/NeedsReviewButton";
import { StatsCard } from "src/components/StatsCard";
import { useAutoRefresh } from "src/utils";
@@ -52,7 +52,7 @@ export const Stats = () => {
</Flex>
<Flex flexWrap="wrap" gap={4}>
- <NeedsReviewButton />
+ <NeedsReviewButtonWithModal />
<StatsCard
colorScheme="failed"
diff --git
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
index c8b1fd4db64..b6eda5ccaa6 100644
---
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
@@ -34,6 +34,8 @@ type HITLResponseFormProps = {
readonly hitlDetail: {
task_instance: TaskInstanceHistoryResponse;
} & Omit<HITLDetailHistory, "task_instance">;
+ readonly namespace?: string;
+ readonly onResponded?: () => void;
};
const isHighlightOption = (
@@ -54,11 +56,11 @@ const isHighlightOption = (
return isSelected ?? isDefault ?? !Boolean(hitlDetail.defaults);
};
-export const HITLResponseForm = ({ hitlDetail }: HITLResponseFormProps) => {
+export const HITLResponseForm = ({ hitlDetail, namespace = "hitl", onResponded
}: HITLResponseFormProps) => {
const { t: translate } = useTranslation("hitl");
const [errors, setErrors] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
- const { paramsDict } = useParamStore("hitl");
+ const { paramsDict } = useParamStore(namespace);
const [searchParams] = useSearchParams();
const { preloadedHITLOptions } = getPreloadHITLFormData(searchParams,
hitlDetail);
@@ -76,6 +78,7 @@ export const HITLResponseForm = ({ hitlDetail }:
HITLResponseFormProps) => {
dagId: hitlDetail.task_instance.dag_id,
dagRunId: hitlDetail.task_instance.dag_run_id,
mapIndex: hitlDetail.task_instance.map_index,
+ onSuccess: onResponded,
taskId: hitlDetail.task_instance.task_id,
});
@@ -118,7 +121,7 @@ export const HITLResponseForm = ({ hitlDetail }:
HITLResponseFormProps) => {
}}
isHITL
key={hitlDetail.subject}
- namespace="hitl"
+ namespace={namespace}
noAccordion
setError={setErrors}
/>
diff --git
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
index 4ad3c945cf4..71a0faaaf7b 100644
---
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
@@ -16,10 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { VStack } from "@chakra-ui/react";
+import { HStack, VStack } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
+import type { ReactNode } from "react";
+import { useState } from "react";
import { useTranslation } from "react-i18next";
+import { LuPanelRightOpen } from "react-icons/lu";
import { useParams, useSearchParams } from "react-router-dom";
import { useTaskInstanceServiceGetHitlDetails } from "openapi/queries";
@@ -27,10 +30,11 @@ import type { HITLDetail } from
"openapi/requests/types.gen";
import { DataTable } from "src/components/DataTable";
import { useTableURLState } from "src/components/DataTable/useTableUrlState";
import { ErrorAlert } from "src/components/ErrorAlert";
+import { HITLReviewDrawer } from
"src/components/HITLReview/HITLReviewDrawer.tsx";
import { StateBadge } from "src/components/StateBadge";
import Time from "src/components/Time";
import { TruncatedText } from "src/components/TruncatedText";
-import { RouterLink } from "src/components/ui";
+import { IconButton, RouterLink } from "src/components/ui";
import { SearchParamsKeys, type SearchParamsKeysType } from
"src/constants/searchParams";
import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch";
import { useAutoRefresh } from "src/utils";
@@ -54,13 +58,50 @@ const {
TASK_ID_PATTERN,
}: SearchParamsKeysType = SearchParamsKeys;
+const HITLReviewDrawerButton = ({
+ detail,
+ onOpen,
+}: {
+ readonly detail: HITLDetail;
+ readonly onOpen: (detail: HITLDetail) => void;
+}) => {
+ const { t: translate } = useTranslation("hitl");
+
+ return (
+ <IconButton label={translate("review.openReviewDrawer")} onClick={() =>
onOpen(detail)}>
+ <LuPanelRightOpen />
+ </IconButton>
+ );
+};
+
+const useHITLReviewDrawer = () => {
+ const [selectedDetail, setSelectedDetail] = useState<HITLDetail |
undefined>(undefined);
+
+ const openHITLReviewDrawer = (detail: HITLDetail) => {
+ setSelectedDetail(detail);
+ };
+
+ const closeHITLReviewDrawer = () => {
+ setSelectedDetail(undefined);
+ };
+
+ return {
+ closeHITLReviewDrawer,
+ isHITLReviewDrawerOpen: selectedDetail !== undefined,
+ openHITLReviewDrawer,
+ selectedDetail,
+ };
+};
+
const taskInstanceColumns = ({
dagId,
+ renderHITLReviewDrawerButton,
runId,
taskId,
translate,
}: {
dagId?: string;
+ renderHITLReviewDrawerButton?: (detail: HITLDetail) => ReactNode;
runId?: string;
taskId?: string;
translate: TFunction;
@@ -68,14 +109,21 @@ const taskInstanceColumns = ({
{
accessorKey: "task_instance_state",
cell: ({ row: { original } }: HITLRow) => (
- <StateBadge
state={original.task_instance.state}>{getHITLState(translate,
original)}</StateBadge>
+ <HStack justifyContent="space-between">
+ <StateBadge
state={original.task_instance.state}>{getHITLState(translate,
original)}</StateBadge>
+ {renderHITLReviewDrawerButton?.(original)}
+ </HStack>
),
header: translate("requiredActionState"),
},
{
accessorKey: "subject",
cell: ({ row: { original } }: HITLRow) => (
- <RouterLink fontWeight="bold"
to={`${getTaskInstanceLink(original.task_instance)}/required_actions`}>
+ <RouterLink
+ fontWeight="bold"
+ onClick={(event) => event.stopPropagation()}
+ to={`${getTaskInstanceLink(original.task_instance)}/required_actions`}
+ >
<TruncatedText text={original.subject} />
</RouterLink>
),
@@ -87,7 +135,10 @@ const taskInstanceColumns = ({
{
accessorKey: "task_instance.dag_id",
cell: ({ row: { original } }: HITLRow) => (
- <RouterLink to={`/dags/${original.task_instance.dag_id}`}>
+ <RouterLink
+ onClick={(event) => event.stopPropagation()}
+ to={`/dags/${original.task_instance.dag_id}`}
+ >
<TruncatedText text={original.task_instance.dag_display_name} />
</RouterLink>
),
@@ -102,6 +153,7 @@ const taskInstanceColumns = ({
accessorKey: "run_id",
cell: ({ row: { original } }: HITLRow) => (
<RouterLink
+ onClick={(event) => event.stopPropagation()}
to={`/dags/${original.task_instance.dag_id}/runs/${original.task_instance.dag_run_id}`}
>
<TruncatedText text={original.task_instance.dag_run_id} />
@@ -127,6 +179,7 @@ const taskInstanceColumns = ({
cell: ({ row: { original } }: HITLRow) => (
<RouterLink
fontWeight="bold"
+ onClick={(event) => event.stopPropagation()}
to={`${getTaskInstanceLink(original.task_instance)}/required_actions`}
>
<TruncatedText text={original.task_instance.task_display_name} />
@@ -162,9 +215,15 @@ const taskInstanceColumns = ({
},
];
-export const HITLTaskInstances = () => {
+export const HITLTaskInstances = ({
+ enableHITLReviewDrawer = false,
+}: {
+ readonly enableHITLReviewDrawer?: boolean;
+}) => {
const { t: translate } = useTranslation("hitl");
const { dagId, runId, taskId } = useParams();
+ const { closeHITLReviewDrawer, isHITLReviewDrawerOpen, openHITLReviewDrawer,
selectedDetail } =
+ useHITLReviewDrawer();
const [searchParams, setSearchParams] = useSearchParams();
const { setTableURLState, tableURLState } = useTableURLState();
const { pagination, sorting } = tableURLState;
@@ -248,6 +307,9 @@ export const HITLTaskInstances = () => {
const columns = taskInstanceColumns({
dagId,
+ renderHITLReviewDrawerButton: enableHITLReviewDrawer
+ ? (detail) => <HITLReviewDrawerButton detail={detail}
onOpen={openHITLReviewDrawer} />
+ : undefined,
runId,
taskId,
translate,
@@ -266,6 +328,13 @@ export const HITLTaskInstances = () => {
onStateChange={setTableURLState}
total={data?.total_entries}
/>
+ {enableHITLReviewDrawer ? (
+ <HITLReviewDrawer
+ detail={selectedDetail}
+ onClose={closeHITLReviewDrawer}
+ open={isHITLReviewDrawerOpen}
+ />
+ ) : null}
</VStack>
);
};
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
index d9a56dd796d..acba855305f 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
@@ -27,6 +27,7 @@ import { DagVersion } from "src/components/DagVersion";
import { HeaderCard } from "src/components/HeaderCard";
import { LimitedItemsList } from "src/components/LimitedItemsList";
import { MarkRunAsButton } from "src/components/MarkAs";
+import { NeedsReviewButtonWithModal } from "src/components/NeedsReviewButton";
import NotePreview from "src/components/NotePreview";
import { RunTypeIcon } from "src/components/RunTypeIcon";
import Time from "src/components/Time";
@@ -53,6 +54,7 @@ export const Header = ({ dagRun }: { readonly dagRun:
DAGRunResponse }) => {
<HeaderCard
actions={
<>
+ <NeedsReviewButtonWithModal dagId={dagId} runId={dagRunId} />
<ClearRunButton dagRun={dagRun} isHotkeyEnabled />
<MarkRunAsButton dagRun={dagRun} isHotkeyEnabled />
<DeleteRunButton dagRun={dagRun} />
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
index 2ec4fb8fc6c..ea49ef73b7c 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
@@ -18,13 +18,12 @@
*/
import { ReactFlowProvider } from "@xyflow/react";
import { useTranslation } from "react-i18next";
-import { FiCode, FiDatabase, FiUser } from "react-icons/fi";
+import { FiCode, FiDatabase } from "react-icons/fi";
import { MdDetails, MdOutlineEventNote, MdOutlineTask } from "react-icons/md";
import { useParams } from "react-router-dom";
import { useDagRunServiceGetDagRun } from "openapi/queries";
import { usePluginTabs } from "src/hooks/usePluginTabs";
-import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
import { isStatePending, useAutoRefresh } from "src/utils";
@@ -39,7 +38,6 @@ export const Run = () => {
const tabs = [
{ icon: <MdOutlineTask />, label: translate("tabs.taskInstances"), value:
"" },
- { icon: <FiUser />, label: translate("tabs.requiredActions"), value:
"required_actions" },
{ icon: <FiDatabase />, label: translate("tabs.assetEvents"), value:
"asset_events" },
{ icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value:
"events" },
{ icon: <FiCode />, label: translate("tabs.code"), value: "code" },
@@ -64,13 +62,9 @@ export const Run = () => {
},
);
- const { tabs: displayTabs } = useRequiredActionTabs({ dagId, dagRunId: runId
}, tabs, {
- refetchInterval: isStatePending(dagRun?.state) ? refetchInterval : false,
- });
-
return (
<ReactFlowProvider>
- <DetailsLayout error={error} isLoading={isLoading} tabs={displayTabs}>
+ <DetailsLayout error={error} isLoading={isLoading} tabs={tabs}>
{dagRun === undefined ? undefined : <Header dagRun={dagRun} />}
</DetailsLayout>
</ReactFlowProvider>
diff --git a/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
index b75bca8cfbd..283a8a10233 100644
--- a/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
@@ -22,6 +22,7 @@ import { useTranslation } from "react-i18next";
import {
UseDagRunServiceGetDagRunKeyFn,
+ useDagServiceGetDagsUiKey,
useDagRunServiceGetDagRunsKey,
UseGanttServiceGetGanttDataKeyFn,
useTaskInstanceServiceGetHitlDetailsKey,
@@ -41,23 +42,26 @@ export const useUpdateHITLDetail = ({
dagId,
dagRunId,
mapIndex,
+ onSuccess,
taskId,
}: {
dagId: string;
dagRunId: string;
mapIndex: number | undefined;
+ onSuccess?: () => void;
taskId: string;
}) => {
const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(undefined);
const { t: translate } = useTranslation("hitl");
- const onSuccess = async () => {
+ const handleSuccess = async () => {
const queryKeys = [
UseDagRunServiceGetDagRunKeyFn({ dagId, dagRunId }),
[useDagRunServiceGetDagRunsKey],
[useTaskInstanceServiceGetTaskInstancesKey, { dagId, dagRunId }],
[useTaskInstanceServiceGetTaskInstanceKey, { dagId, dagRunId, mapIndex,
taskId }],
- [useTaskInstanceServiceGetHitlDetailsKey, { dagIdPrefixPattern: dagId,
dagRunId }],
+ [useDagServiceGetDagsUiKey],
+ [useTaskInstanceServiceGetHitlDetailsKey],
[useTaskInstanceServiceGetHitlDetailKey, { dagId, dagRunId }],
[useTaskInstanceServiceGetHitlDetailTryDetailKey, { dagId, dagRunId }],
UseGanttServiceGetGanttDataKeyFn({ dagId, runId: dagRunId }),
@@ -73,6 +77,7 @@ export const useUpdateHITLDetail = ({
title: translate("response.success", { taskId }),
type: "success",
});
+ onSuccess?.();
};
const onError = (apiError: unknown) => {
@@ -81,7 +86,7 @@ export const useUpdateHITLDetail = ({
const { isPending, mutate } = useTaskInstanceServiceUpdateHitlDetail({
onError,
- onSuccess,
+ onSuccess: handleSuccess,
});
const updateHITLResponse = (updateHITLResponseRequestBody:
HITLResponseParams) => {
diff --git a/airflow-core/src/airflow/ui/src/router.tsx
b/airflow-core/src/airflow/ui/src/router.tsx
index 2245fcc0421..d0e5ad25658 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -102,7 +102,7 @@ export const routerConfig = [
index: true,
},
{
- element: <HITLTaskInstances />,
+ element: <HITLTaskInstances enableHITLReviewDrawer />,
path: "required_actions",
},
{
@@ -188,7 +188,9 @@ export const routerConfig = [
{ element: <DagRuns />, path: "runs" },
{ element: <Tasks />, path: "tasks" },
{ element: <Calendar />, path: "calendar" },
- { element: <HITLTaskInstances />, path: "required_actions" },
+ // The Required Actions tab is now a button + modal; this keeps old
/required_actions
+ // deep links alive by rendering the overview, where the route sync
opens the modal.
+ { element: <Overview />, path: "required_actions" },
{ element: <Backfills />, path: "backfills" },
{ element: <Events />, path: "events" },
{ element: <Code />, path: "code" },
@@ -201,7 +203,9 @@ export const routerConfig = [
{
children: [
{ element: <TaskInstances />, index: true },
- { element: <HITLTaskInstances />, path: "required_actions" },
+ // The Required Actions tab is now a button + modal; this keeps old
/required_actions
+ // deep links alive by rendering the task instances, where the route
sync opens the modal.
+ { element: <TaskInstances />, path: "required_actions" },
{ element: <Events />, path: "events" },
{ element: <Code />, path: "code" },
{ element: <DagRunDetails />, path: "details" },
diff --git
a/airflow-core/src/airflow/ui/tests/e2e/components/HITLReviewDrawer.ts
b/airflow-core/src/airflow/ui/tests/e2e/components/HITLReviewDrawer.ts
new file mode 100644
index 00000000000..09990c6cd07
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/components/HITLReviewDrawer.ts
@@ -0,0 +1,32 @@
+/*!
+ * 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 { expect, type Locator, type Page } from "@playwright/test";
+
+export class HITLReviewDrawer {
+ private readonly root: Locator;
+
+ public constructor(page: Page) {
+ this.root = page.getByRole("dialog", { name: "Required Actions" });
+ }
+
+ public async expectOpenWith(dagId: string): Promise<void> {
+ await expect(this.root).toBeVisible();
+ await expect(this.root).toContainText(dagId);
+ }
+}
diff --git
a/airflow-core/src/airflow/ui/tests/e2e/components/HITLReviewModal.ts
b/airflow-core/src/airflow/ui/tests/e2e/components/HITLReviewModal.ts
new file mode 100644
index 00000000000..b9975762c0e
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/components/HITLReviewModal.ts
@@ -0,0 +1,32 @@
+/*!
+ * 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 { expect, type Locator, type Page } from "@playwright/test";
+
+export class HITLReviewModal {
+ private readonly root: Locator;
+
+ public constructor(page: Page) {
+ this.root = page.getByRole("dialog", { name: "Required Actions" });
+ }
+
+ public async expectOpenWith(dagId: string): Promise<void> {
+ await expect(this.root).toBeVisible();
+ await expect(this.root).toContainText(dagId, { timeout: 60_000 });
+ }
+}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/data.ts
b/airflow-core/src/airflow/ui/tests/e2e/fixtures/data.ts
index bc0352916d2..adf0ac37a82 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/fixtures/data.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/data.ts
@@ -37,6 +37,7 @@ import {
waitForDagReady,
waitForDagRunStatus,
} from "../utils/api/dag-runs";
+import { setupPendingHITLFlowViaAPI } from "../utils/api/hitl";
import { uniqueRunId } from "../utils/shared";
import { test as base } from "./pom";
@@ -54,11 +55,18 @@ export type SuccessAndFailedRunsData = {
successRun: DagRunFixtureData;
};
+export type HITLRunFixtureData = {
+ dagId: string;
+ runId: string;
+};
+
export type DataWorkerFixtures = {
/** Ensures the default test Dag is parsed and ready. Worker-scoped, no
cleanup needed. */
dagReady: string;
/** A Dag run triggered via scheduler and completed. Worker-scoped with
auto-cleanup. */
executedDagRun: DagRunFixtureData;
+ /** A pending HITL Dag run. Worker-scoped with auto-cleanup. */
+ pendingHITLRun: HITLRunFixtureData;
/** Two Dag runs: one success, one failed. Worker-scoped with auto-cleanup.
*/
successAndFailedRuns: SuccessAndFailedRunsData;
/** A Dag run in "success" state (API-only, no scheduler). Worker-scoped
with auto-cleanup. */
@@ -146,6 +154,24 @@ export const test = base.extend<DataTestFixtures,
DataWorkerFixtures>({
{ scope: "worker", timeout: 180_000 },
],
+ pendingHITLRun: [
+ async ({ authenticatedRequest }, use) => {
+ const dagId = testConfig.testDag.hitlId;
+ let runId: string | undefined;
+
+ try {
+ runId = await setupPendingHITLFlowViaAPI(authenticatedRequest, dagId);
+
+ await use({ dagId, runId });
+ } finally {
+ if (runId !== undefined) {
+ await safeCleanupDagRun(authenticatedRequest, dagId, runId);
+ }
+ }
+ },
+ { scope: "worker", timeout: 120_000 },
+ ],
+
successAndFailedRuns: [
async ({ authenticatedRequest }, use, workerInfo) => {
const dagId = testConfig.testDag.id;
diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/pom.ts
b/airflow-core/src/airflow/ui/tests/e2e/fixtures/pom.ts
index d561be27faa..ccd72dc82c4 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/fixtures/pom.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/pom.ts
@@ -33,6 +33,8 @@ import { ConfigurationPage } from
"../pages/ConfigurationPage";
import { ConnectionsPage } from "../pages/ConnectionsPage";
import { DagCalendarTab } from "../pages/DagCalendarTab";
import { DagCodePage } from "../pages/DagCodePage";
+import { DagDetailPage } from "../pages/DagDetailPage";
+import { DagRunPage } from "../pages/DagRunPage";
import { DagRunsPage } from "../pages/DagRunsPage";
import { DagRunsTabPage } from "../pages/DagRunsTabPage";
import { DagsPage } from "../pages/DagsPage";
@@ -60,6 +62,8 @@ export type PomFixtures = {
connectionsPage: ConnectionsPage;
dagCalendarTab: DagCalendarTab;
dagCodePage: DagCodePage;
+ dagDetailPage: DagDetailPage;
+ dagRunPage: DagRunPage;
dagRunsPage: DagRunsPage;
dagRunsTabPage: DagRunsTabPage;
dagsPage: DagsPage;
@@ -111,6 +115,12 @@ export const test = base.extend<PomFixtures,
PomWorkerFixtures>({
dagCodePage: async ({ page }, use) => {
await use(new DagCodePage(page));
},
+ dagDetailPage: async ({ page }, use) => {
+ await use(new DagDetailPage(page));
+ },
+ dagRunPage: async ({ page }, use) => {
+ await use(new DagRunPage(page));
+ },
dagRunsPage: async ({ page }, use) => {
await use(new DagRunsPage(page));
},
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagDetailPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/DagDetailPage.ts
new file mode 100644
index 00000000000..2a3335b679d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagDetailPage.ts
@@ -0,0 +1,48 @@
+/*!
+ * 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 type { Locator, Page } from "@playwright/test";
+import { HITLReviewModal } from "tests/e2e/components/HITLReviewModal";
+import { BasePage } from "tests/e2e/pages/BasePage";
+
+export class DagDetailPage extends BasePage {
+ public readonly hitlReviewModal: HITLReviewModal;
+ public readonly requiredActionsButton: Locator;
+
+ public constructor(page: Page) {
+ super(page);
+ this.hitlReviewModal = new HITLReviewModal(page);
+ this.requiredActionsButton = page.getByRole("button", { name: "Required
Actions" });
+ }
+
+ public static getDagDetailRequiredActionsUrl(dagId: string): string {
+ return this.getDagDetailUrl(dagId) + `/required_actions`;
+ }
+
+ public static getDagDetailUrl(dagId: string): string {
+ return `/dags/${dagId}`;
+ }
+
+ public async navigateToDagDetail(dagId: string): Promise<void> {
+ await this.navigateTo(DagDetailPage.getDagDetailUrl(dagId));
+ }
+
+ public async navigateToDagDetailRequiredActions(dagId: string):
Promise<void> {
+ await this.navigateTo(DagDetailPage.getDagDetailRequiredActionsUrl(dagId));
+ }
+}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunPage.ts
new file mode 100644
index 00000000000..d6d915593fd
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunPage.ts
@@ -0,0 +1,48 @@
+/*!
+ * 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 type { Locator, Page } from "@playwright/test";
+import { HITLReviewModal } from "tests/e2e/components/HITLReviewModal";
+import { BasePage } from "tests/e2e/pages/BasePage";
+
+export class DagRunPage extends BasePage {
+ public readonly hitlReviewModal: HITLReviewModal;
+ public readonly requiredActionsButton: Locator;
+
+ public constructor(page: Page) {
+ super(page);
+ this.hitlReviewModal = new HITLReviewModal(page);
+ this.requiredActionsButton = page.getByRole("button", { name: "Required
Actions" });
+ }
+
+ public static getDagRunRequiredActionsUrl(dagId: string, dagRunId: string):
string {
+ return this.getDagRunUrl(dagId, dagRunId) + `/required_actions`;
+ }
+
+ public static getDagRunUrl(dagId: string, dagRunId: string): string {
+ return `/dags/${dagId}/runs/${dagRunId}`;
+ }
+
+ public async navigateToDagRun(dagId: string, dagRunId: string):
Promise<void> {
+ await this.navigateTo(DagRunPage.getDagRunUrl(dagId, dagRunId));
+ }
+
+ public async navigateToDagRunRequiredActions(dagId: string, dagRunId:
string): Promise<void> {
+ await this.navigateTo(DagRunPage.getDagRunRequiredActionsUrl(dagId,
dagRunId));
+ }
+}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
index 564e5d420bd..f83134d6f93 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
@@ -17,6 +17,7 @@
* under the License.
*/
import { expect, type Locator, type Page, type Response } from
"@playwright/test";
+import { HITLReviewModal } from "tests/e2e/components/HITLReviewModal";
import { BasePage } from "tests/e2e/pages/BasePage";
import type { DAGRunResponse } from "openapi/requests/types.gen";
@@ -32,6 +33,8 @@ export class DagsPage extends BasePage {
public readonly cardViewButton: Locator;
public readonly confirmButton: Locator;
public readonly failedFilter: Locator;
+ public readonly hitlReviewModal: HITLReviewModal;
+ public readonly needsReviewBadges: Locator;
public readonly needsReviewFilter: Locator;
public readonly operatorFilter: Locator;
public readonly queuedFilter: Locator;
@@ -63,6 +66,8 @@ export class DagsPage extends BasePage {
this.tableViewButton = page.getByRole("button", { name: "Show table view"
});
this.successFilter = page.getByRole("button", { name: "Success" });
this.failedFilter = page.getByRole("button", { name: "Failed" });
+ this.hitlReviewModal = new HITLReviewModal(page);
+ this.needsReviewBadges = page.getByTestId("needs-review-badge");
this.runningFilter = page.getByRole("button", { name: "Running" });
this.queuedFilter = page.getByRole("button", { name: "Queued" });
// Uses testId because this button's text is driven by an i18n key.
@@ -195,6 +200,22 @@ export class DagsPage extends BasePage {
return texts.map((text) => text.trim()).filter((text) => text !== "");
}
+ public async getDagNeedsReviewBadgeOnCard(dagId: string): Promise<Locator> {
+ const dagCard = this.page.getByTestId("dag-card").filter({ hasText: dagId
});
+
+ await expect(dagCard).toBeVisible({ timeout: 60_000 });
+
+ return dagCard.getByTestId("needs-review-badge");
+ }
+
+ public async getDagNeedsReviewBadgeOnTable(dagId: string): Promise<Locator> {
+ const dagRow =
this.page.getByTestId("table-list").getByRole("row").filter({ hasText: dagId });
+
+ await expect(dagRow).toBeVisible({ timeout: 60_000 });
+
+ return dagRow.getByTestId("needs-review-badge");
+ }
+
/**
* Get count of Dags on current page
*/
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/HomePage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/HomePage.ts
index 299b7a1cfd7..b1367458de5 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/HomePage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/HomePage.ts
@@ -17,6 +17,7 @@
* under the License.
*/
import { expect, type Locator, type Page } from "@playwright/test";
+import { HITLReviewModal } from "tests/e2e/components/HITLReviewModal";
import { BasePage } from "tests/e2e/pages/BasePage";
/**
@@ -35,8 +36,10 @@ export class HomePage extends BasePage {
public readonly healthSection: Locator;
public readonly historicalMetricsSection: Locator;
+ public readonly hitlReviewModal: HITLReviewModal;
public readonly metaDatabaseHealth: Locator;
public readonly poolSummarySection: Locator;
+ public readonly requiredActionsButton: Locator;
public readonly runningDagsCard: Locator;
public readonly schedulerHealth: Locator;
@@ -55,10 +58,12 @@ export class HomePage extends BasePage {
this.runningDagsCard = page.getByRole("link", { name: /running/i });
this.activeDagsCard = page.getByRole("link", { name: /active/i });
this.dagImportErrorsCard = page.getByRole("button", { name: "Dag Import
Errors" });
+ this.requiredActionsButton = page.getByRole("button", { name: "Required
Actions" });
// Navigate to parent via ".." since there are no ARIA landmark/region
roles on these sections.
this.statsSection = page.getByRole("heading", { name: "Stats"
}).locator("..");
this.healthSection = page.getByRole("heading", { name: "Health"
}).locator("..");
+ this.hitlReviewModal = new HITLReviewModal(page);
this.metaDatabaseHealth = page.getByText("Metadatabase").first();
this.schedulerHealth = page.getByText("Scheduler").first();
this.triggererHealth = page.getByText("Triggerer").first();
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/RequiredActionsPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/RequiredActionsPage.ts
index d524c9f7756..750a077f420 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/RequiredActionsPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/RequiredActionsPage.ts
@@ -16,8 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { expect, type APIRequestContext, type Locator, type Page } from
"@playwright/test";
+import { type APIRequestContext, expect, type Locator, type Page } from
"@playwright/test";
import { testConfig } from "playwright.config";
+import { HITLReviewDrawer } from "tests/e2e/components/HITLReviewDrawer";
import { apiTriggerDagRun, waitForDagReady } from
"tests/e2e/utils/api/dag-runs";
import { BasePage } from "./BasePage";
@@ -25,6 +26,7 @@ import { BasePage } from "./BasePage";
export class RequiredActionsPage extends BasePage {
public readonly actionsTable: Locator;
public readonly emptyStateMessage: Locator;
+ public readonly hitlReviewDrawer: HITLReviewDrawer;
public readonly pageHeading: Locator;
// Standalone API context — page.request degrades after many navigations in
WebKit.
@@ -36,16 +38,41 @@ export class RequiredActionsPage extends BasePage {
this.pageHeading = page.getByRole("heading").filter({ hasText: /required
action/i });
this.actionsTable = page.getByTestId("table-list");
this.emptyStateMessage = page.getByText(/no required actions found/i);
+ this.hitlReviewDrawer = new HITLReviewDrawer(page);
+ }
+
+ public static getPendingRequiredActionsUrl(): string {
+ return `${this.getRequiredActionsUrl()}?response_received=false`;
}
public static getRequiredActionsUrl(): string {
return "/required_actions";
}
+ public async clickReviewDrawerButton(dagId: string): Promise<void> {
+ const actionRow = this.getActionRow(dagId);
+ const reviewDrawerButton = actionRow.getByRole("button", { name: "Open
Review Drawer" });
+
+ await expect(reviewDrawerButton).toBeVisible({ timeout: 30_000 });
+ await reviewDrawerButton.click();
+ }
+
+ public getActionRow(dagId: string): Locator {
+ return this.actionsTable
+ .getByRole("row")
+ .filter({ has: this.page.getByRole("cell", { name: dagId }) })
+ .first();
+ }
+
public async isEmptyStateDisplayed(): Promise<boolean> {
return this.emptyStateMessage.isVisible();
}
+ public async navigateToPendingRequiredActionsPage(): Promise<void> {
+ await this.navigateTo(RequiredActionsPage.getPendingRequiredActionsUrl());
+ await expect(this.pageHeading).toBeVisible({ timeout: 30_000 });
+ }
+
public async navigateToRequiredActionsPage(): Promise<void> {
await expect(async () => {
await this.navigateTo(RequiredActionsPage.getRequiredActionsUrl());
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-detail.spec.ts
b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-detail.spec.ts
new file mode 100644
index 00000000000..bf35acc4d0d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-detail.spec.ts
@@ -0,0 +1,43 @@
+/*!
+ * 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 { expect, test } from "tests/e2e/fixtures";
+
+test.describe("Dag Detail Page", () => {
+ test("verify HITL review modal opens from Dag detail", async ({
dagDetailPage, pendingHITLRun }) => {
+ test.slow();
+
+ await dagDetailPage.navigateToDagDetail(pendingHITLRun.dagId);
+
+ await expect(dagDetailPage.requiredActionsButton).toBeVisible({ timeout:
60_000 });
+ await dagDetailPage.requiredActionsButton.click();
+
+ await dagDetailPage.hitlReviewModal.expectOpenWith(pendingHITLRun.dagId);
+ });
+
+ test("verify HITL review modal opens from the Dag required actions route",
async ({
+ dagDetailPage,
+ pendingHITLRun,
+ }) => {
+ test.slow();
+
+ await
dagDetailPage.navigateToDagDetailRequiredActions(pendingHITLRun.dagId);
+
+ await dagDetailPage.hitlReviewModal.expectOpenWith(pendingHITLRun.dagId);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-run.spec.ts
b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-run.spec.ts
new file mode 100644
index 00000000000..704d4682f2d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-run.spec.ts
@@ -0,0 +1,43 @@
+/*!
+ * 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 { expect, test } from "tests/e2e/fixtures";
+
+test.describe("Dag Run Page", () => {
+ test("verify HITL review modal opens from Dag run details", async ({
dagRunPage, pendingHITLRun }) => {
+ test.slow();
+
+ await dagRunPage.navigateToDagRun(pendingHITLRun.dagId,
pendingHITLRun.runId);
+
+ await expect(dagRunPage.requiredActionsButton).toBeVisible({ timeout:
60_000 });
+ await dagRunPage.requiredActionsButton.click();
+
+ await dagRunPage.hitlReviewModal.expectOpenWith(pendingHITLRun.runId);
+ });
+
+ test("verify HITL review modal opens from the required actions route", async
({
+ dagRunPage,
+ pendingHITLRun,
+ }) => {
+ test.slow();
+
+ await dagRunPage.navigateToDagRunRequiredActions(pendingHITLRun.dagId,
pendingHITLRun.runId);
+
+ await dagRunPage.hitlReviewModal.expectOpenWith(pendingHITLRun.runId);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dags-list.spec.ts
b/airflow-core/src/airflow/ui/tests/e2e/specs/dags-list.spec.ts
index 8b91cf44847..798a910a52e 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/specs/dags-list.spec.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dags-list.spec.ts
@@ -99,6 +99,48 @@ test.describe("Dags List Display", () => {
await dagsPage.waitForDagList();
await expect(dagsPage.getDagLink(testDagId)).toBeVisible();
});
+
+ test("verify HITL review modal opens from the needs review badge in table
view", async ({
+ dagsPage,
+ pendingHITLRun,
+ }) => {
+ test.slow();
+
+ await dagsPage.navigate();
+ await dagsPage.waitForDagList();
+ await dagsPage.switchToTableView();
+
+ await expect(dagsPage.needsReviewFilter).toBeVisible({ timeout: 30_000 });
+ await dagsPage.needsReviewFilter.click();
+
+ const needsReviewBadge = await
dagsPage.getDagNeedsReviewBadgeOnTable(pendingHITLRun.dagId);
+
+ await expect(needsReviewBadge).toBeVisible({ timeout: 30_000 });
+ await needsReviewBadge.click();
+
+ await dagsPage.hitlReviewModal.expectOpenWith(pendingHITLRun.dagId);
+ });
+
+ test("verify HITL review modal opens from the needs review badge in card
view", async ({
+ dagsPage,
+ pendingHITLRun,
+ }) => {
+ test.slow();
+
+ await dagsPage.navigate();
+ await dagsPage.waitForDagList();
+ await dagsPage.switchToCardView();
+
+ await expect(dagsPage.needsReviewFilter).toBeVisible({ timeout: 30_000 });
+ await dagsPage.needsReviewFilter.click();
+
+ const needsReviewBadge = await
dagsPage.getDagNeedsReviewBadgeOnCard(pendingHITLRun.dagId);
+
+ await expect(needsReviewBadge).toBeVisible({ timeout: 30_000 });
+ await needsReviewBadge.click();
+
+ await dagsPage.hitlReviewModal.expectOpenWith(pendingHITLRun.dagId);
+ });
});
test.describe("Dags View Toggle", () => {
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/home-dashboard.spec.ts
b/airflow-core/src/airflow/ui/tests/e2e/specs/home-dashboard.spec.ts
index 97f22068504..1c03a2c6995 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/specs/home-dashboard.spec.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/home-dashboard.spec.ts
@@ -74,6 +74,21 @@ test.describe("Dashboard Metrics Display", () => {
await expect(homePage.welcomeHeading).toBeVisible();
});
+ test("verify HITL review modal opens from the Required Actions button",
async ({
+ homePage,
+ pendingHITLRun,
+ }) => {
+ test.slow();
+
+ await homePage.navigate();
+ await homePage.waitForDashboardLoad();
+
+ await expect(homePage.requiredActionsButton).toBeVisible({ timeout: 30_000
});
+ await homePage.requiredActionsButton.click();
+
+ await homePage.hitlReviewModal.expectOpenWith(pendingHITLRun.dagId);
+ });
+
test("should update metrics when Dag is triggered", async ({ dagRunCleanup,
dagsPage, homePage }) => {
test.slow();
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/requiredAction.spec.ts
b/airflow-core/src/airflow/ui/tests/e2e/specs/requiredAction.spec.ts
index b55dc5ec330..f8e9f4f5a0d 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/specs/requiredAction.spec.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/requiredAction.spec.ts
@@ -53,4 +53,15 @@ test.describe("Verify Required Action page", () => {
await expect(page.locator("th").filter({ hasText: "Response created at"
})).toBeVisible();
await expect(page.locator("th").filter({ hasText: "Response received at"
})).toBeVisible();
});
+
+ test("verify HITL Review drawer opens from clicking a pending action state",
async ({
+ pendingHITLRun,
+ requiredActionsPage,
+ }) => {
+ await requiredActionsPage.navigateToPendingRequiredActionsPage();
+
+ await requiredActionsPage.clickReviewDrawerButton(pendingHITLRun.dagId);
+
+ await
requiredActionsPage.hitlReviewDrawer.expectOpenWith(pendingHITLRun.dagId);
+ });
});
diff --git a/airflow-core/src/airflow/ui/tests/e2e/utils/api/hitl.ts
b/airflow-core/src/airflow/ui/tests/e2e/utils/api/hitl.ts
index de3113f9c9a..159dd0b05c7 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/utils/api/hitl.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/utils/api/hitl.ts
@@ -192,3 +192,25 @@ export async function setupHITLFlowViaAPI(
return dagRunId;
}
+
+export async function setupPendingHITLFlowViaAPI(source: RequestLike, dagId:
string): Promise<string> {
+ const request = getRequestContext(source);
+
+ await waitForDagReady(request, dagId);
+ const response = await request.patch(`${baseUrl}/api/v2/dags/${dagId}`, {
data: { is_paused: false } });
+
+ if (!response.ok()) {
+ throw new Error(`HITL response failed (${response.status()})`);
+ }
+
+ const { dagRunId } = await apiTriggerDagRun(request, dagId);
+
+ await waitForTaskInstanceState(request, {
+ dagId,
+ expectedState: "awaiting_input",
+ runId: dagRunId,
+ taskId: "wait_for_input",
+ });
+
+ return dagRunId;
+}