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 ec850dfd627 Improve HITL form UX (#68397)
ec850dfd627 is described below
commit ec850dfd627af4505a3914112fce3f6a2667d924
Author: Brent Bovenzi <[email protected]>
AuthorDate: Mon Jun 15 23:15:10 2026 -0600
Improve HITL form UX (#68397)
* Improve HITL form UX
* Fix required action filter
* Fix required action filter button
* Fix HITL form showing [object Object] default and missing subject heading
* Invalidate cash after success hitl submission
---------
Co-authored-by: pierrejeambrun <[email protected]>
---
.../src/components/FlexibleForm/FlexibleForm.tsx | 74 ++++++++++++++++++++++
.../airflow/ui/src/components/NeedsReviewBadge.tsx | 2 +-
.../ui/src/components/NeedsReviewButton.tsx | 2 +-
.../src/airflow/ui/src/constants/filterConfigs.tsx | 2 +-
.../DagsList/DagsFilters/RequiredActionFilter.tsx | 5 +-
.../pages/HITLTaskInstances/HITLResponseForm.tsx | 14 ++--
.../airflow/ui/src/queries/useUpdateHITLDetail.ts | 2 +
airflow-core/src/airflow/ui/src/utils/hitl.ts | 6 +-
8 files changed, 87 insertions(+), 20 deletions(-)
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
index 792ca6e25a8..2e1e5c5568a 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
@@ -28,6 +28,28 @@ import { Accordion } from "../ui";
import { Row } from "./Row";
import { isRequired } from "./isParamRequired";
+const FlatSection = ({
+ children,
+ hasError,
+ title,
+}: {
+ readonly children: React.ReactNode;
+ readonly hasError: boolean;
+ readonly title: string;
+}) => (
+ <Stack gap={2}>
+ <Text color={hasError ? "fg.error" : undefined} fontWeight="medium">
+ {title}
+ {hasError ? (
+ <Icon color="fg.error" margin="-1" ml={1}>
+ <MdError />
+ </Icon>
+ ) : undefined}
+ </Text>
+ {children}
+ </Stack>
+);
+
const computeSectionErrors = (
params: Record<string, ParamSpec>,
defaultSection: string,
@@ -54,6 +76,7 @@ export type FlexibleFormProps = {
readonly isHITL?: boolean;
readonly key?: string;
readonly namespace?: string;
+ readonly noAccordion?: boolean;
readonly setError: (error: boolean) => void;
readonly subHeader?: string;
};
@@ -65,6 +88,7 @@ export const FlexibleForm = ({
initialParamsDict,
isHITL,
namespace = "default",
+ noAccordion,
setError,
subHeader,
}: FlexibleFormProps) => {
@@ -111,6 +135,56 @@ export const FlexibleForm = ({
setError(Boolean(error) || newSectionError.size > 0);
};
+ if (noAccordion) {
+ return Object.keys(params).length > 0 ? (
+ <>
+ {Object.entries(params).map(([, secParam]) => {
+ const currentSection = secParam.schema.section ??
flexibleFormDefaultSection;
+
+ if (processedSections.has(currentSection)) {
+ return undefined;
+ }
+ processedSections.set(currentSection, true);
+
+ return (
+ <FlatSection
+ hasError={Boolean(sectionError.get(currentSection))}
+ key={currentSection}
+ title={currentSection}
+ >
+ {Boolean(subHeader) ? (
+ <Text color="fg.muted" fontSize="xs">
+ {subHeader}
+ </Text>
+ ) : undefined}
+ <Stack separator={<StackSeparator py={2} />}>
+ {Boolean(flexFormDescription) ? (
+ <ReactMarkdown>{flexFormDescription}</ReactMarkdown>
+ ) : undefined}
+ {Object.entries(params)
+ .filter(
+ ([, param]) =>
+ param.schema.section === currentSection ||
+ (currentSection === flexibleFormDefaultSection &&
!Boolean(param.schema.section)),
+ )
+ .map(([name]) => (
+ <Row key={name} name={name} namespace={namespace}
onUpdate={onUpdate} />
+ ))}
+ </Stack>
+ </FlatSection>
+ );
+ })}
+ </>
+ ) : isHITL ? (
+ <FlatSection
+ hasError={Boolean(sectionError.get(flexibleFormDefaultSection))}
+ title={flexibleFormDefaultSection}
+ >
+ {Boolean(flexFormDescription) ?
<ReactMarkdown>{flexFormDescription}</ReactMarkdown> : undefined}
+ </FlatSection>
+ ) : undefined;
+ }
+
return Object.keys(params).length > 0 ? (
Object.entries(params).map(([, secParam]) => {
const currentSection = secParam.schema.section ??
flexibleFormDefaultSection;
diff --git a/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx
b/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx
index 7ab09137629..e270a32e455 100644
--- a/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx
+++ b/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx
@@ -43,7 +43,7 @@ export const NeedsReviewBadge = ({ dagId, pendingActions }:
Props) => {
data-testid="needs-review-badge"
to={`/dags/${dagId}/required_actions?${SearchParamsKeys.RESPONSE_RECEIVED}=false`}
>
- <StateBadge colorPalette="deferred" fontSize="md" variant="solid">
+ <StateBadge colorPalette="awaiting_input" fontSize="md"
variant="solid">
<LuUserRoundPen />
{pendingActions.length}
</StateBadge>
diff --git a/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx
b/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx
index 8e48c0c6fbe..ade24b26f78 100644
--- a/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx
@@ -58,7 +58,7 @@ export const NeedsReviewButton = ({
return hitlTIsCount > 0 ? (
<Box maxW="250px">
<StatsCard
- colorScheme="deferred"
+ colorScheme="awaiting_input"
count={hitlTIsCount}
icon={<LuUserRoundPen />}
isLoading={isLoading}
diff --git a/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
b/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
index 6e1c6870cba..3da64282ff5 100644
--- a/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
+++ b/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
@@ -280,7 +280,7 @@ export const useFilterConfigs = () => {
options: [
{ label: translate("hitl:filters.response.all"), value: "all" },
{
- label: <StateBadge
state="deferred">{translate("hitl:filters.response.pending")}</StateBadge>,
+ label: <StateBadge
state="awaiting_input">{translate("hitl:filters.response.pending")}</StateBadge>,
value: "false",
},
{
diff --git
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/RequiredActionFilter.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/RequiredActionFilter.tsx
index d9c6fed2518..8a56b5a1ff1 100644
---
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/RequiredActionFilter.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/RequiredActionFilter.tsx
@@ -18,7 +18,6 @@
*/
import { Button } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
-import { LuUserRoundPen } from "react-icons/lu";
import { StateBadge } from "src/components/StateBadge";
@@ -36,9 +35,7 @@ export const RequiredActionFilter = ({ needsReview, onToggle
}: Props) => {
onClick={onToggle}
variant={needsReview ? "solid" : "outline"}
>
- <StateBadge colorPalette="deferred">
- <LuUserRoundPen />
- </StateBadge>
+ <StateBadge state="awaiting_input" />
{translate("requiredAction_other")}
</Button>
);
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 87bc57c1d42..c8b1fd4db64 100644
---
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Button, Box, Spacer, HStack, Accordion, Text } from
"@chakra-ui/react";
+import { Button, Box, Spacer, HStack, Text } from "@chakra-ui/react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FiSend } from "react-icons/fi";
@@ -108,14 +108,7 @@ export const HITLResponseForm = ({ hitlDetail }:
HITLResponseFormProps) => {
: undefined}
</Text>
) : undefined}
- <Accordion.Root
- defaultValue={[hitlDetail.subject]}
- mb={4}
- mt={4}
- overflow="visible"
- size="lg"
- variant="enclosed"
- >
+ <Box mb={4} mt={4} overflow="visible">
<FlexibleForm
disabled={!isPending || hitlDetail.response_received}
flexFormDescription={hitlDetail.body ?? undefined}
@@ -126,9 +119,10 @@ export const HITLResponseForm = ({ hitlDetail }:
HITLResponseFormProps) => {
isHITL
key={hitlDetail.subject}
namespace="hitl"
+ noAccordion
setError={setErrors}
/>
- </Accordion.Root>
+ </Box>
<Box as="footer" display="flex" justifyContent="flex-end" mt={4}>
<HStack w="full">
diff --git a/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
index 358b8b87bbc..b75bca8cfbd 100644
--- a/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
@@ -26,6 +26,7 @@ import {
UseGanttServiceGetGanttDataKeyFn,
useTaskInstanceServiceGetHitlDetailsKey,
useTaskInstanceServiceGetHitlDetailKey,
+ useTaskInstanceServiceGetHitlDetailTryDetailKey,
useTaskInstanceServiceUpdateHitlDetail,
useTaskInstanceServiceGetTaskInstanceKey,
useTaskInstanceServiceGetTaskInstancesKey,
@@ -58,6 +59,7 @@ export const useUpdateHITLDetail = ({
[useTaskInstanceServiceGetTaskInstanceKey, { dagId, dagRunId, mapIndex,
taskId }],
[useTaskInstanceServiceGetHitlDetailsKey, { dagIdPrefixPattern: dagId,
dagRunId }],
[useTaskInstanceServiceGetHitlDetailKey, { dagId, dagRunId }],
+ [useTaskInstanceServiceGetHitlDetailTryDetailKey, { dagId, dagRunId }],
UseGanttServiceGetGanttDataKeyFn({ dagId, runId: dagRunId }),
...tiPerAttemptQueryKeys,
];
diff --git a/airflow-core/src/airflow/ui/src/utils/hitl.ts
b/airflow-core/src/airflow/ui/src/utils/hitl.ts
index ab1ab135526..cba56351d45 100644
--- a/airflow-core/src/airflow/ui/src/utils/hitl.ts
+++ b/airflow-core/src/airflow/ui/src/utils/hitl.ts
@@ -76,7 +76,7 @@ export const getHITLParamsDict = (
searchParams: URLSearchParams,
): ParamsSpec => {
const paramsDict: ParamsSpec = {};
- const { preloadedHITLOptions, preloadedHITLParams } =
getPreloadHITLFormData(searchParams, hitlDetail);
+ const { preloadedHITLOptions } = getPreloadHITLFormData(searchParams,
hitlDetail);
const isApprovalTask =
hitlDetail.options.includes("Approve") &&
hitlDetail.options.includes("Reject") &&
@@ -120,7 +120,7 @@ export const getHITLParamsDict = (
const paramData = hitlDetail.params[key] as ParamsSpec | undefined;
// Check if there's a preloaded value from URL params
- let finalValue = preloadedHITLParams[key] ?? value;
+ let finalValue = hitlDetail.params_input?.[key] ?? paramData?.value ??
value;
// If preloaded value is a string that might be JSON, try to parse it
if (typeof finalValue === "string" && finalValue.trim().startsWith("{"))
{
@@ -170,7 +170,7 @@ export const getHITLParamsDict = (
paramsDict[key] = {
description,
schema,
- value: paramData?.value ?? finalValue,
+ value: finalValue ?? paramData?.value,
};
});
}