This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 18506de1629 Feat note modal (#68362)
18506de1629 is described below
commit 18506de1629ce67310d89998adab45f9bc15f464
Author: Brent Bovenzi <[email protected]>
AuthorDate: Tue Jun 16 04:25:32 2026 -0600
Feat note modal (#68362)
* Update note UX
* Remove useEffects
* code cleanup
* Fix markdown
* Revert back to modal, with preview
* Add ellipsis for cutoff notes
* Fix note editor showing unsaved text after cancel and stray scrollbar
---------
Co-authored-by: pierrejeambrun <[email protected]>
---
.../airflow/ui/public/i18n/locales/en/common.json | 5 +-
.../ui/src/components/EditableMarkdownArea.tsx | 85 ----------
.../ui/src/components/EditableMarkdownButton.tsx | 60 ++-----
.../src/airflow/ui/src/components/HeaderCard.tsx | 8 +-
.../ui/src/components/MarkdownModal.test.tsx | 131 +++++++++++++++
.../airflow/ui/src/components/MarkdownModal.tsx | 181 +++++++++++++++++++++
.../src/airflow/ui/src/components/NoteIcon.tsx | 25 +++
.../airflow/ui/src/components/NotePreview.test.tsx | 98 +++++++++++
.../src/airflow/ui/src/components/NotePreview.tsx | 104 ++++++++++++
.../airflow/ui/src/components/ReactMarkdown.tsx | 34 ++--
.../ui/src/components/ui/ResizableWrapper.tsx | 10 +-
.../src/airflow/ui/src/pages/DagRuns/DagRuns.tsx | 2 +
.../airflow/ui/src/pages/DagRuns/RunNoteButton.tsx | 42 +++++
.../src/airflow/ui/src/pages/Run/Header.tsx | 43 ++---
.../airflow/ui/src/pages/TaskInstance/Header.tsx | 52 ++----
.../pages/TaskInstances/TaskInstanceNoteButton.tsx | 42 +++++
.../ui/src/pages/TaskInstances/TaskInstances.tsx | 2 +
.../src/airflow/ui/src/queries/useDagRunNote.ts | 41 +++++
.../src/airflow/ui/src/queries/useNoteEditor.ts | 48 ++++++
.../airflow/ui/src/queries/useTaskInstanceNote.ts | 52 ++++++
20 files changed, 833 insertions(+), 232 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 32f9988a239..3aaa5115bb2 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -179,9 +179,12 @@
"note": {
"add": "Add a note",
"dagRun": "Dag Run Note",
+ "edit": "Edit note",
"label": "Note",
"placeholder": "Add a note...",
- "taskInstance": "Task Instance Note"
+ "preview": "Preview",
+ "taskInstance": "Task Instance Note",
+ "write": "Write"
},
"overallStatus": "Overall Status",
"partitionedDagRun_one": "Partitioned Dag Run",
diff --git
a/airflow-core/src/airflow/ui/src/components/EditableMarkdownArea.tsx
b/airflow-core/src/airflow/ui/src/components/EditableMarkdownArea.tsx
deleted file mode 100644
index 3b467734a09..00000000000
--- a/airflow-core/src/airflow/ui/src/components/EditableMarkdownArea.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-/*!
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { Box, VStack, Editable, Text } from "@chakra-ui/react";
-import type { ChangeEvent } from "react";
-import { useState, useRef } from "react";
-
-import ReactMarkdown from "./ReactMarkdown";
-
-const EditableMarkdownArea = ({
- mdContent,
- onBlur,
- placeholder,
- setMdContent,
-}: {
- readonly mdContent?: string | null;
- readonly onBlur?: () => void;
- readonly placeholder?: string | null;
- readonly setMdContent: (value: string) => void;
-}) => {
- const [currentValue, setCurrentValue] = useState(mdContent ?? "");
- const prevMdContentRef = useRef(mdContent);
-
- // Sync local state with prop changes
- if (mdContent !== prevMdContentRef.current) {
- setCurrentValue(mdContent ?? "");
- prevMdContentRef.current = mdContent;
- }
-
- return (
- <Box height="100%" p={4} width="100%">
- <Editable.Root
- height="100%"
- onBlur={onBlur}
- onChange={(event: ChangeEvent<HTMLInputElement>) => {
- const { value } = event.target;
-
- setCurrentValue(value);
- setMdContent(value);
- }}
- value={currentValue}
- >
- <Editable.Preview
- _hover={{ backgroundColor: "transparent" }}
- alignItems="flex-start"
- as={VStack}
- gap="0"
- height="100%"
- overflowY="auto"
- width="100%"
- >
- {Boolean(currentValue) ? (
- <ReactMarkdown>{currentValue}</ReactMarkdown>
- ) : (
- <Text color="fg.subtle">{placeholder}</Text>
- )}
- </Editable.Preview>
- <Editable.Textarea
- data-testid="markdown-input"
- height="100%"
- overflowY="auto"
- placeholder={placeholder ?? ""}
- resize="none"
- />
- </Editable.Root>
- </Box>
- );
-};
-
-export default EditableMarkdownArea;
diff --git
a/airflow-core/src/airflow/ui/src/components/EditableMarkdownButton.tsx
b/airflow-core/src/airflow/ui/src/components/EditableMarkdownButton.tsx
index 7d2f5e61c8e..e75f4daa275 100644
--- a/airflow-core/src/airflow/ui/src/components/EditableMarkdownButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/EditableMarkdownButton.tsx
@@ -16,15 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Button, Flex, Heading, VStack } from "@chakra-ui/react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
-import { PiNoteBold, PiNoteBlankBold } from "react-icons/pi";
-import { IconButton, Dialog } from "src/components/ui";
-import { ResizableWrapper, MARKDOWN_DIALOG_STORAGE_KEY } from
"src/components/ui/ResizableWrapper";
+import { IconButton } from "src/components/ui";
-import EditableMarkdownArea from "./EditableMarkdownArea";
+import MarkdownModal from "./MarkdownModal";
+import NoteIcon from "./NoteIcon";
const EditableMarkdownButton = ({
header,
@@ -47,7 +45,6 @@ const EditableMarkdownButton = ({
const [isOpen, setIsOpen] = useState(false);
const hasContent = Boolean(mdContent?.trim());
- const noteIcon = hasContent ? <PiNoteBold /> : <PiNoteBlankBold />;
const label = hasContent ? translate("note.label") : translate("note.add");
const handleOpen = () => {
@@ -60,47 +57,18 @@ const EditableMarkdownButton = ({
return (
<>
<IconButton label={label} onClick={handleOpen}>
- {noteIcon}
+ <NoteIcon hasNote={hasContent} />
</IconButton>
- <Dialog.Root
- data-testid="markdown-modal"
- lazyMount
- onOpenChange={() => setIsOpen(false)}
- open={isOpen}
- size="md"
- unmountOnExit={true}
- >
- <Dialog.Content backdrop maxHeight="90vh" maxWidth="90vw" padding={0}
width="auto">
- <ResizableWrapper storageKey={MARKDOWN_DIALOG_STORAGE_KEY}>
- <Dialog.Header bg="brand.muted" flexShrink={0}>
- <Heading size="xl">{header}</Heading>
- <Dialog.CloseTrigger closeButtonProps={{ size: "xl" }} />
- </Dialog.Header>
- <Dialog.Body alignItems="flex-start" as={VStack} flex="1" gap="0"
overflow="hidden" p={0}>
- <Box flex="1" overflow="hidden" width="100%">
- <EditableMarkdownArea
- mdContent={mdContent}
- placeholder={placeholder}
- setMdContent={setMdContent}
- />
- </Box>
- <Box bg="bg.panel" flexShrink={0} width="100%">
- <Flex justifyContent="end" p={4}>
- <Button
- loading={isPending}
- onClick={() => {
- onConfirm();
- setIsOpen(false);
- }}
- >
- {noteIcon} {translate("modal.confirm")}
- </Button>
- </Flex>
- </Box>
- </Dialog.Body>
- </ResizableWrapper>
- </Dialog.Content>
- </Dialog.Root>
+ <MarkdownModal
+ header={header}
+ isOpen={isOpen}
+ isPending={isPending}
+ mdContent={mdContent}
+ onClose={() => setIsOpen(false)}
+ onConfirm={onConfirm}
+ placeholder={placeholder}
+ setMdContent={setMdContent}
+ />
</>
);
};
diff --git a/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
b/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
index 7908076bc4d..bb4ad874d16 100644
--- a/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
+++ b/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
@@ -39,13 +39,7 @@ export const HeaderCard = ({ actions, icon, state, stats,
subTitle, title }: Pro
const { t: translate } = useTranslation();
return (
- <Box
- borderColor="border.emphasized"
- borderRadius={8}
- borderWidth={1}
- data-testid="header-card"
- overflow="hidden"
- >
+ <Box data-testid="header-card" overflow="hidden">
<DagDeactivatedBanner />
<Box p={2}>
<Flex alignItems="center" flexWrap="wrap"
justifyContent="space-between" mb={2}>
diff --git a/airflow-core/src/airflow/ui/src/components/MarkdownModal.test.tsx
b/airflow-core/src/airflow/ui/src/components/MarkdownModal.test.tsx
new file mode 100644
index 00000000000..383b0c7d9c1
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/MarkdownModal.test.tsx
@@ -0,0 +1,131 @@
+/*!
+ * 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";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import MarkdownModal, { MAX_NOTE_LENGTH } from "./MarkdownModal";
+
+const defaultProps = {
+ header: "Note",
+ isOpen: true,
+ isPending: false,
+ mdContent: "An existing note",
+ onClose: vi.fn(),
+ onConfirm: vi.fn(),
+ placeholder: "Add a note...",
+ setMdContent: vi.fn(),
+};
+
+const renderModal = (props: Partial<typeof defaultProps> = {}) =>
+ render(<MarkdownModal {...defaultProps} {...props} />, { wrapper: Wrapper });
+
+describe("MarkdownModal", () => {
+ it("shows rendered markdown (read-only) with an edit toggle for an existing
note", () => {
+ renderModal();
+ expect(screen.getByText("An existing note", { selector: "p"
})).toBeInTheDocument();
+ expect(screen.queryByTestId("markdown-input")).toBeNull();
+ expect(screen.getByTestId("edit-markdown")).toBeInTheDocument();
+ });
+
+ it("reveals the textarea when the edit toggle is clicked", () => {
+ renderModal();
+ fireEvent.click(screen.getByTestId("edit-markdown"));
+ expect(screen.getByTestId("markdown-input")).toBeInTheDocument();
+ });
+
+ it("opens straight into editing when there is no content", () => {
+ renderModal({ mdContent: "" });
+ expect(screen.getByTestId("markdown-input")).toBeInTheDocument();
+ });
+
+ it("calls setMdContent as the textarea value changes", () => {
+ const setMdContent = vi.fn();
+
+ renderModal({ mdContent: "", setMdContent });
+ fireEvent.change(screen.getByTestId("markdown-input"), { target: { value:
"new content" } });
+ expect(setMdContent).toHaveBeenCalledWith("new content");
+ });
+
+ describe("character limit", () => {
+ it("caps the textarea at the maximum length", () => {
+ renderModal({ mdContent: "" });
+
expect(screen.getByTestId("markdown-input")).toHaveAttribute("maxlength",
String(MAX_NOTE_LENGTH));
+ });
+
+ it("shows the live character count and keeps saving enabled under the
limit", () => {
+ renderModal({ mdContent: "hello" });
+ fireEvent.click(screen.getByTestId("edit-markdown"));
+ expect(screen.getByText(`5/${MAX_NOTE_LENGTH}`)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /confirm/iu })).toBeEnabled();
+ });
+
+ it("keeps saving enabled at exactly the limit", () => {
+ renderModal({ mdContent: "x".repeat(MAX_NOTE_LENGTH) });
+ fireEvent.click(screen.getByTestId("edit-markdown"));
+
expect(screen.getByText(`${MAX_NOTE_LENGTH}/${MAX_NOTE_LENGTH}`)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /confirm/iu })).toBeEnabled();
+ });
+
+ it("shows the count and disables saving when over the limit", () => {
+ renderModal({ mdContent: "x".repeat(MAX_NOTE_LENGTH + 1) });
+ fireEvent.click(screen.getByTestId("edit-markdown"));
+ expect(screen.getByText(`${MAX_NOTE_LENGTH +
1}/${MAX_NOTE_LENGTH}`)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /confirm/iu })).toBeDisabled();
+ });
+ });
+
+ it("toggles between the textarea and a rendered preview while editing", ()
=> {
+ // The modal is controlled, so drive mdContent from a stateful wrapper.
+ const ControlledModal = () => {
+ const [value, setValue] = useState("");
+
+ return <MarkdownModal {...defaultProps} mdContent={value}
setMdContent={setValue} />;
+ };
+
+ render(<ControlledModal />, { wrapper: Wrapper });
+
+ // Starts in the editor
+ fireEvent.change(screen.getByTestId("markdown-input"), { target: { value:
"**bold**" } });
+ fireEvent.click(screen.getByTestId("preview-toggle"));
+
+ // Preview hides the textarea and renders the markdown
+ expect(screen.queryByTestId("markdown-input")).toBeNull();
+ expect(screen.getByText("bold", { selector: "strong"
})).toBeInTheDocument();
+
+ // Toggling back returns to the editor
+ fireEvent.click(screen.getByTestId("preview-toggle"));
+ expect(screen.getByTestId("markdown-input")).toBeInTheDocument();
+ });
+
+ it("confirms and closes when save is clicked", () => {
+ const onClose = vi.fn();
+ const onConfirm = vi.fn();
+
+ renderModal({ mdContent: "valid note", onClose, onConfirm });
+ fireEvent.click(screen.getByTestId("edit-markdown"));
+ fireEvent.click(screen.getByRole("button", { name: /confirm/iu }));
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/MarkdownModal.tsx
b/airflow-core/src/airflow/ui/src/components/MarkdownModal.tsx
new file mode 100644
index 00000000000..80288a37d3f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/MarkdownModal.tsx
@@ -0,0 +1,181 @@
+/*!
+ * 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, Button, Flex, Heading, HStack, Text, Textarea, VStack } from
"@chakra-ui/react";
+import type { ChangeEvent } from "react";
+import { useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { FiEdit, FiEye } from "react-icons/fi";
+
+import NoteIcon from "src/components/NoteIcon";
+import ReactMarkdown from "src/components/ReactMarkdown";
+import { Dialog } from "src/components/ui";
+import { ResizableWrapper, MARKDOWN_DIALOG_STORAGE_KEY } from
"src/components/ui/ResizableWrapper";
+
+export const MAX_NOTE_LENGTH = 1000;
+
+const MarkdownModal = ({
+ header,
+ isOpen,
+ isPending,
+ mdContent,
+ onClose,
+ onConfirm,
+ placeholder,
+ setMdContent,
+}: {
+ readonly header: string;
+ readonly isOpen: boolean;
+ readonly isPending: boolean;
+ readonly mdContent?: string | null;
+ readonly onClose: () => void;
+ readonly onConfirm: () => void;
+ readonly placeholder: string;
+ readonly setMdContent: (value: string) => void;
+}) => {
+ const { t: translate } = useTranslation("common");
+
+ const hasContent = Boolean(mdContent?.trim());
+ // Open straight into editing when there's nothing to read; otherwise show
the
+ // rendered note (links clickable) and let the edit button reveal the
textarea.
+ const [isEditing, setIsEditing] = useState(!hasContent);
+ // While editing, toggle between the textarea and a rendered preview.
+ const [showPreview, setShowPreview] = useState(false);
+
+ // Track the saved content while closed; it freezes on open, so dismissing
(X / Esc / backdrop)
+ // restores it and discards the unsaved draft instead of leaking it into the
shared note state.
+ // Confirm closes through onClose directly and keeps the edited value.
+ const openContentRef = useRef(mdContent ?? "");
+
+ if (!isOpen) {
+ openContentRef.current = mdContent ?? "";
+ }
+
+ const onDismiss = () => {
+ setMdContent(openContentRef.current);
+ onClose();
+ };
+
+ const length = mdContent?.length ?? 0;
+ // Existing notes may already exceed the limit (created before validation or
+ // via the API); block saving until trimmed rather than silently truncating.
+ const isOverLimit = length > MAX_NOTE_LENGTH;
+
+ // Textarea only while editing and not previewing; otherwise show the
rendered markdown.
+ const showEditor = isEditing && !showPreview;
+ const renderedContent = hasContent ? (
+ <ReactMarkdown>{mdContent ?? ""}</ReactMarkdown>
+ ) : (
+ <Text color="fg.subtle">{placeholder}</Text>
+ );
+
+ return (
+ <Dialog.Root
+ data-testid="markdown-modal"
+ lazyMount
+ onOpenChange={({ open: nextOpen }) => {
+ if (!nextOpen) {
+ onDismiss();
+ }
+ }}
+ open={isOpen}
+ size="xl"
+ unmountOnExit={true}
+ >
+ <Dialog.Content backdrop maxHeight="90vh" maxWidth="90vw" padding={0}
width="auto">
+ <ResizableWrapper
+ defaultSize={{ height: 700, width: 1000 }}
+ maxConstraints={[1600, 1000]}
+ minSize={{ height: 600, width: 800 }}
+ storageKey={MARKDOWN_DIALOG_STORAGE_KEY}
+ >
+ <Dialog.Header bg="brand.muted" flexShrink={0}>
+ <Heading size="xl">{header}</Heading>
+ <Dialog.CloseTrigger closeButtonProps={{ size: "xl" }} />
+ </Dialog.Header>
+ <Dialog.Body alignItems="stretch" as={VStack} flex="1" gap="0"
overflow="hidden" p={0}>
+ <Box display="flex" flex="1" flexDirection="column" minH={0}
overflow="hidden" p={4} width="100%">
+ {showEditor ? (
+ <Textarea
+ data-testid="markdown-input"
+ flex="1"
+ maxLength={MAX_NOTE_LENGTH}
+ minH={0}
+ onChange={(event: ChangeEvent<HTMLTextAreaElement>) =>
setMdContent(event.target.value)}
+ placeholder={placeholder}
+ resize="none"
+ value={mdContent ?? ""}
+ />
+ ) : (
+ <Box flex="1" minH={0} overflow="auto">
+ {renderedContent}
+ </Box>
+ )}
+ </Box>
+ <Box bg="bg.panel" flexShrink={0} width="100%">
+ <Flex alignItems="center" gap={4} justifyContent="space-between"
p={4}>
+ {isEditing ? (
+ <Text color={isOverLimit ? "fg.error" : "fg.muted"}
fontSize="sm">
+ {length}/{MAX_NOTE_LENGTH}
+ </Text>
+ ) : (
+ <Box />
+ )}
+ {isEditing ? (
+ <HStack gap={2}>
+ <Button
+ data-testid="preview-toggle"
+ onClick={() => setShowPreview((prev) => !prev)}
+ variant="outline"
+ >
+ {showPreview ? <FiEdit /> : <FiEye />}
+ {showPreview ? translate("note.write") :
translate("note.preview")}
+ </Button>
+ <Button
+ disabled={isOverLimit}
+ loading={isPending}
+ onClick={() => {
+ onConfirm();
+ onClose();
+ }}
+ >
+ <NoteIcon hasNote={hasContent} />
{translate("modal.confirm")}
+ </Button>
+ </HStack>
+ ) : (
+ <Button
+ data-testid="edit-markdown"
+ onClick={() => {
+ setShowPreview(false);
+ setIsEditing(true);
+ }}
+ variant="outline"
+ >
+ <FiEdit /> {translate("edit")}
+ </Button>
+ )}
+ </Flex>
+ </Box>
+ </Dialog.Body>
+ </ResizableWrapper>
+ </Dialog.Content>
+ </Dialog.Root>
+ );
+};
+
+export default MarkdownModal;
diff --git a/airflow-core/src/airflow/ui/src/components/NoteIcon.tsx
b/airflow-core/src/airflow/ui/src/components/NoteIcon.tsx
new file mode 100644
index 00000000000..ac67a31d434
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/NoteIcon.tsx
@@ -0,0 +1,25 @@
+/*!
+ * 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 { PiNoteBold, PiNoteBlankBold } from "react-icons/pi";
+
+/** Filled note icon when a note exists, blank otherwise. */
+const NoteIcon = ({ hasNote }: { readonly hasNote: boolean }) =>
+ hasNote ? <PiNoteBold /> : <PiNoteBlankBold />;
+
+export default NoteIcon;
diff --git a/airflow-core/src/airflow/ui/src/components/NotePreview.test.tsx
b/airflow-core/src/airflow/ui/src/components/NotePreview.test.tsx
new file mode 100644
index 00000000000..498cb54c42d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/NotePreview.test.tsx
@@ -0,0 +1,98 @@
+/*!
+ * 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";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import NotePreview from "./NotePreview";
+
+const defaultProps = {
+ header: "Note",
+ isPending: false,
+ note: "",
+ onOpen: vi.fn(),
+ onSave: vi.fn(),
+ setNote: vi.fn(),
+};
+
+const renderNote = (props: Partial<typeof defaultProps> = {}) =>
+ render(<NotePreview {...defaultProps} {...props} />, { wrapper: Wrapper });
+
+describe("NotePreview", () => {
+ describe("preview", () => {
+ it("shows the placeholder when there is no note", () => {
+ renderNote();
+ expect(screen.getByText("note.placeholder")).toBeInTheDocument();
+ });
+
+ it("renders the note content as a read-only preview", () => {
+ const note = "A short single line note";
+
+ renderNote({ note });
+ expect(screen.getByText(note, { selector: "p" })).toBeInTheDocument();
+ });
+
+ it("shows only the first non-empty line with an ellipsis for a multiline
note", () => {
+ renderNote({ note: "First line\nSecond line" });
+ expect(screen.getByText(/First line …/u)).toBeInTheDocument();
+ expect(screen.queryByText("Second line")).toBeNull();
+ });
+
+ it("does not append an ellipsis for a single-line note", () => {
+ renderNote({ note: "Only line" });
+ expect(screen.getByText("Only line", { selector: "p"
})).toBeInTheDocument();
+ expect(screen.queryByText(/…/u)).toBeNull();
+ });
+
+ it("does not render an editable textarea before opening the modal", () => {
+ renderNote({ note: "some note" });
+ expect(screen.queryByTestId("markdown-input")).toBeNull();
+ });
+ });
+
+ describe("opening the modal", () => {
+ it("resets state and opens the modal when the edit button is clicked",
async () => {
+ const onOpen = vi.fn();
+
+ renderNote({ note: "some note", onOpen });
+ fireEvent.click(screen.getByRole("button", { name: "note.edit" }));
+
+ expect(onOpen).toHaveBeenCalledTimes(1);
+ // Modal header confirms the dialog is open
+ expect(await screen.findByRole("heading", { name: "Note"
})).toBeInTheDocument();
+ });
+
+ it("opens an existing note in read mode (markdown shown, textarea
hidden)", async () => {
+ renderNote({ note: "some note" });
+ fireEvent.click(screen.getByRole("button", { name: "note.edit" }));
+
+ expect(await screen.findByTestId("edit-markdown")).toBeInTheDocument();
+ expect(screen.queryByTestId("markdown-input")).toBeNull();
+ });
+
+ it("opens an empty note straight into editing", async () => {
+ renderNote({ note: "" });
+ fireEvent.click(screen.getByRole("button", { name: "note.edit" }));
+
+ expect(await screen.findByTestId("markdown-input")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/NotePreview.tsx
b/airflow-core/src/airflow/ui/src/components/NotePreview.tsx
new file mode 100644
index 00000000000..1f2a2b094c6
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/NotePreview.tsx
@@ -0,0 +1,104 @@
+/*!
+ * 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, Text } from "@chakra-ui/react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { FiEdit } from "react-icons/fi";
+
+import MarkdownModal from "src/components/MarkdownModal";
+import NoteIcon from "src/components/NoteIcon";
+import ReactMarkdown from "src/components/ReactMarkdown";
+import { IconButton } from "src/components/ui";
+
+type Props = {
+ readonly header: string;
+ readonly isPending: boolean;
+ readonly note: string | null;
+ readonly onOpen: () => void;
+ readonly onSave: () => void;
+ readonly setNote: (value: string) => void;
+};
+
+const NotePreview = ({ header, isPending, note, onOpen, onSave, setNote }:
Props) => {
+ const { t: translate } = useTranslation();
+ const [isOpen, setIsOpen] = useState(false);
+
+ const hasNote = note !== null && note.trim().length > 0;
+ // Only the first non-empty line drives the single-line preview; append an
+ // ellipsis when there's more content below it that the preview hides.
+ const meaningfulLines = hasNote ? note.split("\n").filter((line) =>
line.trim().length > 0) : [];
+ const firstLine = meaningfulLines[0] ?? "";
+ const previewContent = meaningfulLines.length > 1 ? `${firstLine} …` :
firstLine;
+
+ const handleOpen = () => {
+ onOpen();
+ setIsOpen(true);
+ };
+
+ return (
+ <HStack alignItems="center" gap={2} px={3}>
+ {/* Icon indicates whether a note exists */}
+ <Box color={hasNote ? undefined : "fg.subtle"} flexShrink={0}
fontSize="md">
+ <NoteIcon hasNote={hasNote} />
+ </Box>
+
+ {/* Read-only single-line preview — links stay clickable, edits go
through the modal */}
+ <Box
+ flex="1"
+ fontSize="sm"
+ overflow="hidden"
+ style={{
+ display: "-webkit-box",
+ WebkitBoxOrient: "vertical",
+ WebkitLineClamp: 1,
+ whiteSpace: "normal",
+ }}
+ >
+ {hasNote ? (
+ <ReactMarkdown>{previewContent}</ReactMarkdown>
+ ) : (
+ <Text color="fg.subtle">{translate("note.placeholder")}</Text>
+ )}
+ </Box>
+
+ <IconButton
+ flexShrink={0}
+ label={translate("note.edit")}
+ onClick={handleOpen}
+ size="xs"
+ variant="ghost"
+ >
+ <FiEdit />
+ </IconButton>
+
+ <MarkdownModal
+ header={header}
+ isOpen={isOpen}
+ isPending={isPending}
+ mdContent={note}
+ onClose={() => setIsOpen(false)}
+ onConfirm={onSave}
+ placeholder={translate("note.placeholder")}
+ setMdContent={setNote}
+ />
+ </HStack>
+ );
+};
+
+export default NotePreview;
diff --git a/airflow-core/src/airflow/ui/src/components/ReactMarkdown.tsx
b/airflow-core/src/airflow/ui/src/components/ReactMarkdown.tsx
index 57a585a2b91..9a89c105fef 100644
--- a/airflow-core/src/airflow/ui/src/components/ReactMarkdown.tsx
+++ b/airflow-core/src/airflow/ui/src/components/ReactMarkdown.tsx
@@ -64,7 +64,7 @@ const LinkComponent = ({
readonly href: string;
readonly title?: string;
}) => (
- <Link color="fg.info" fontWeight="bold" href={href} title={title}>
+ <Link color="fg.info" fontWeight="bold" href={href} rel="noopener
noreferrer" target="_blank" title={title}>
{children}
</Link>
);
@@ -109,16 +109,13 @@ const UlComponent = ({ children }: PropsWithChildren) => (
// Factory function for the code component that needs style
const createCodeComponent =
(style: typeof oneDark | typeof oneLight) =>
- ({
- children,
- className,
- inline,
- }: {
- readonly children: ReactNode;
- readonly className?: string;
- readonly inline?: boolean;
- }) => {
- if (inline) {
+ ({ children, className }: { readonly children: ReactNode; readonly
className?: string }) => {
+ // react-markdown v9 no longer passes an `inline` prop. Only fenced code
+ // blocks carry a `language-*` class; everything else is inline code and
+ // must render as a <span> (a block <div> inside a <p> breaks hydration).
+ const match = /language-(?<lang>\w+)/u.exec(className ?? "");
+
+ if (!match) {
return (
<Code display="inline" p={2}>
{children}
@@ -126,9 +123,7 @@ const createCodeComponent =
);
}
- // Extract language from className (format: "language-python")
- const match = /language-(?<lang>\w+)/u.exec(className ?? "");
- const language = match?.groups?.lang;
+ const language = match.groups?.lang;
// Safely extract string content from children
let childString = "";
@@ -146,7 +141,7 @@ const createCodeComponent =
);
};
-const ReactMarkdown = (props: Options) => {
+const ReactMarkdown = ({ components: componentOverrides, ...restProps }:
Options) => {
const { colorMode } = useColorMode();
const style = colorMode === "dark" ? oneDark : oneLight;
@@ -180,7 +175,14 @@ const ReactMarkdown = (props: Options) => {
ul: UlComponent,
};
- return <ReactMD components={components as Components} {...props}
remarkPlugins={[remarkGfm]} skipHtml />;
+ return (
+ <ReactMD
+ components={{ ...components, ...componentOverrides } as Components}
+ {...restProps}
+ remarkPlugins={[remarkGfm]}
+ skipHtml
+ />
+ );
};
export default ReactMarkdown;
diff --git a/airflow-core/src/airflow/ui/src/components/ui/ResizableWrapper.tsx
b/airflow-core/src/airflow/ui/src/components/ui/ResizableWrapper.tsx
index d448a181bbc..b10d4b78910 100644
--- a/airflow-core/src/airflow/ui/src/components/ui/ResizableWrapper.tsx
+++ b/airflow-core/src/airflow/ui/src/components/ui/ResizableWrapper.tsx
@@ -24,6 +24,7 @@ import { useLocalStorage } from "usehooks-ts";
type ResizableWrapperProps = {
readonly defaultSize?: { height: number; width: number };
readonly maxConstraints?: [width: number, height: number];
+ readonly minSize?: { height: number; width: number };
readonly storageKey: string;
} & PropsWithChildren;
@@ -36,6 +37,7 @@ export const ResizableWrapper = ({
children,
defaultSize = DEFAULT_SIZE,
maxConstraints = MAX_SIZE,
+ minSize = DEFAULT_SIZE,
storageKey,
}: ResizableWrapperProps) => {
const ref = useRef<HTMLDivElement>(null);
@@ -79,13 +81,13 @@ export const ResizableWrapper = ({
overflow: "hidden",
resize: "both",
}}
- height={`${storedSize.height}px`}
+ height={`${Math.max(storedSize.height, minSize.height)}px`}
maxHeight={`${maxConstraints[1]}px`}
maxWidth={`${maxConstraints[0]}px`}
- minHeight={`${DEFAULT_SIZE.height}px`}
- minWidth={`${DEFAULT_SIZE.width}px`}
+ minHeight={`${minSize.height}px`}
+ minWidth={`${minSize.width}px`}
ref={ref}
- width={`${storedSize.width}px`}
+ width={`${Math.max(storedSize.width, minSize.width)}px`}
>
{children}
</Box>
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
index 4443ec53056..971354ebe55 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
@@ -54,6 +54,7 @@ import BulkDeleteDagRunsButton from
"./BulkDeleteDagRunsButton";
import BulkMarkDagRunsAsButton from "./BulkMarkDagRunsAsButton";
import { DagRunsFilters } from "./DagRunsFilters";
import DeleteRunButton from "./DeleteRunButton";
+import RunNoteButton from "./RunNoteButton";
// Matches the identifier the bulk Dag Run endpoint echoes back in its
``success`` /
// ``errors`` lists, so the bulk response can deselect rows directly.
@@ -203,6 +204,7 @@ const runColumns = ({ dagId, translate }: ColumnProps):
Array<ColumnDef<DAGRunRe
accessorKey: "actions",
cell: ({ row }) => (
<Flex justifyContent="end">
+ <RunNoteButton dagRun={row.original} />
<ClearRunButton dagRun={row.original} />
<MarkRunAsButton dagRun={row.original} />
<DeleteRunButton dagRun={row.original} />
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/RunNoteButton.tsx
b/airflow-core/src/airflow/ui/src/pages/DagRuns/RunNoteButton.tsx
new file mode 100644
index 00000000000..f8d5c732866
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/RunNoteButton.tsx
@@ -0,0 +1,42 @@
+/*!
+ * 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 { useTranslation } from "react-i18next";
+
+import type { DAGRunResponse } from "openapi/requests/types.gen";
+import EditableMarkdownButton from "src/components/EditableMarkdownButton";
+import { useDagRunNote } from "src/queries/useDagRunNote";
+
+const RunNoteButton = ({ dagRun }: { readonly dagRun: DAGRunResponse }) => {
+ const { t: translate } = useTranslation();
+ const { isPending, note, onOpen, onSave, setNote } = useDagRunNote(dagRun);
+
+ return (
+ <EditableMarkdownButton
+ header={translate("note.dagRun")}
+ isPending={isPending}
+ mdContent={note}
+ onConfirm={onSave}
+ onOpen={onOpen}
+ placeholder={translate("note.placeholder")}
+ setMdContent={setNote}
+ />
+ );
+};
+
+export default RunNoteButton;
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 18c0e927426..d9a56dd796d 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
@@ -17,7 +17,6 @@
* under the License.
*/
import { HStack, Text, Box } from "@chakra-ui/react";
-import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FiBarChart } from "react-icons/fi";
@@ -25,23 +24,23 @@ import { useDeadlinesServiceGetDagDeadlineAlerts } from
"openapi/queries";
import type { DAGRunResponse } from "openapi/requests/types.gen";
import { ClearRunButton } from "src/components/Clear";
import { DagVersion } from "src/components/DagVersion";
-import EditableMarkdownButton from "src/components/EditableMarkdownButton";
import { HeaderCard } from "src/components/HeaderCard";
import { LimitedItemsList } from "src/components/LimitedItemsList";
import { MarkRunAsButton } from "src/components/MarkAs";
+import NotePreview from "src/components/NotePreview";
import { RunTypeIcon } from "src/components/RunTypeIcon";
import Time from "src/components/Time";
import { RouterLink } from "src/components/ui";
import { SearchParamsKeys } from "src/constants/searchParams";
import DeleteRunButton from "src/pages/DagRuns/DeleteRunButton";
-import { usePatchDagRun } from "src/queries/usePatchDagRun";
+import { useDagRunNote } from "src/queries/useDagRunNote";
import { getDuration } from "src/utils";
import { DeadlineStatus } from "./DeadlineStatus";
export const Header = ({ dagRun }: { readonly dagRun: DAGRunResponse }) => {
const { t: translate } = useTranslation();
- const [note, setNote] = useState<string | null>(dagRun.note);
+ const { isPending, note, onOpen, onSave, setNote } = useDagRunNote(dagRun);
const dagId = dagRun.dag_id;
const dagRunId = dagRun.dag_run_id;
@@ -49,39 +48,11 @@ export const Header = ({ dagRun }: { readonly dagRun:
DAGRunResponse }) => {
const { data: alertData } = useDeadlinesServiceGetDagDeadlineAlerts({ dagId
});
const hasDeadlineAlerts = (alertData?.total_entries ?? 0) > 0;
- const { isPending, mutate } = usePatchDagRun({
- dagId,
- dagRunId,
- });
-
- const onConfirm = () => {
- if (note !== dagRun.note) {
- mutate({
- dagId,
- dagRunId,
- requestBody: { note },
- });
- }
- };
-
- const onOpen = () => {
- setNote(dagRun.note ?? "");
- };
-
return (
<Box>
<HeaderCard
actions={
<>
- <EditableMarkdownButton
- header={translate("note.dagRun")}
- isPending={isPending}
- mdContent={dagRun.note}
- onConfirm={onConfirm}
- onOpen={onOpen}
- placeholder={translate("note.placeholder")}
- setMdContent={setNote}
- />
<ClearRunButton dagRun={dagRun} isHotkeyEnabled />
<MarkRunAsButton dagRun={dagRun} isHotkeyEnabled />
<DeleteRunButton dagRun={dagRun} />
@@ -154,6 +125,14 @@ export const Header = ({ dagRun }: { readonly dagRun:
DAGRunResponse }) => {
]}
title={dagRun.dag_run_id}
/>
+ <NotePreview
+ header={translate("note.dagRun")}
+ isPending={isPending}
+ note={note}
+ onOpen={onOpen}
+ onSave={onSave}
+ setNote={setNote}
+ />
</Box>
);
};
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
index d053c68534c..48364a9e9a6 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
@@ -25,22 +25,16 @@ import type { TaskInstanceResponse } from
"openapi/requests/types.gen";
import { ClearTaskInstanceButton } from "src/components/Clear";
import ClearTaskInstanceDialog from
"src/components/Clear/TaskInstance/ClearTaskInstanceDialog";
import { DagVersion } from "src/components/DagVersion";
-import EditableMarkdownButton from "src/components/EditableMarkdownButton";
import { HeaderCard } from "src/components/HeaderCard";
import { MarkTaskInstanceAsButton } from "src/components/MarkAs";
+import NotePreview from "src/components/NotePreview";
import Time from "src/components/Time";
-import { usePatchTaskInstance } from "src/queries/usePatchTaskInstance";
+import { useTaskInstanceNote } from "src/queries/useTaskInstanceNote";
import { getDuration, renderDuration } from "src/utils";
export const Header = ({ taskInstance }: { readonly taskInstance:
TaskInstanceResponse }) => {
const { t: translate } = useTranslation();
-
- const [note, setNote] = useState<string | null>(taskInstance.note);
-
- const dagId = taskInstance.dag_id;
- const dagRunId = taskInstance.dag_run_id;
- const taskId = taskInstance.task_id;
- const mapIndex = taskInstance.map_index;
+ const { isPending, note, onOpen, onSave, setNote } =
useTaskInstanceNote(taskInstance);
const stats = [
{ label: translate("task.operator"), value: taskInstance.operator_name },
@@ -68,29 +62,6 @@ export const Header = ({ taskInstance }: { readonly
taskInstance: TaskInstanceRe
},
];
- const { isPending, mutate } = usePatchTaskInstance({
- dagId,
- dagRunId,
- mapIndex,
- taskId,
- });
-
- const onConfirm = () => {
- if (note !== taskInstance.note) {
- mutate({
- dagId,
- dagRunId,
- mapIndex,
- requestBody: { note },
- taskId,
- });
- }
- };
-
- const onOpen = () => {
- setNote(taskInstance.note ?? "");
- };
-
// Stable dialog state at header/page level
const [clearOpen, setClearOpen] = useState(false);
@@ -99,15 +70,6 @@ export const Header = ({ taskInstance }: { readonly
taskInstance: TaskInstanceRe
<HeaderCard
actions={
<>
- <EditableMarkdownButton
- header={translate("note.taskInstance")}
- isPending={isPending}
- mdContent={taskInstance.note}
- onConfirm={onConfirm}
- onOpen={onOpen}
- placeholder={translate("note.placeholder")}
- setMdContent={setNote}
- />
<ClearTaskInstanceButton
isHotkeyEnabled
onOpen={() => setClearOpen(true)}
@@ -121,6 +83,14 @@ export const Header = ({ taskInstance }: { readonly
taskInstance: TaskInstanceRe
stats={stats}
title={`${taskInstance.task_display_name}${taskInstance.map_index > -1
? ` [${taskInstance.rendered_map_index ?? taskInstance.map_index}]` : ""}`}
/>
+ <NotePreview
+ header={translate("note.taskInstance")}
+ isPending={isPending}
+ note={note}
+ onOpen={onOpen}
+ onSave={onSave}
+ setNote={setNote}
+ />
<ClearTaskInstanceDialog
onClose={() => setClearOpen(false)}
open={clearOpen}
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstanceNoteButton.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstanceNoteButton.tsx
new file mode 100644
index 00000000000..58d1faa79e9
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstanceNoteButton.tsx
@@ -0,0 +1,42 @@
+/*!
+ * 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 { useTranslation } from "react-i18next";
+
+import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+import EditableMarkdownButton from "src/components/EditableMarkdownButton";
+import { useTaskInstanceNote } from "src/queries/useTaskInstanceNote";
+
+const TaskInstanceNoteButton = ({ taskInstance }: { readonly taskInstance:
TaskInstanceResponse }) => {
+ const { t: translate } = useTranslation();
+ const { isPending, note, onOpen, onSave, setNote } =
useTaskInstanceNote(taskInstance);
+
+ return (
+ <EditableMarkdownButton
+ header={translate("note.taskInstance")}
+ isPending={isPending}
+ mdContent={note}
+ onConfirm={onSave}
+ onOpen={onOpen}
+ placeholder={translate("note.placeholder")}
+ setMdContent={setNote}
+ />
+ );
+};
+
+export default TaskInstanceNoteButton;
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
index ff28b5eb7b5..3249b876621 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
@@ -51,6 +51,7 @@ import BulkClearTaskInstancesButton from
"./BulkClearTaskInstancesButton";
import BulkDeleteTaskInstancesButton from "./BulkDeleteTaskInstancesButton";
import BulkMarkTaskInstancesAsButton from "./BulkMarkTaskInstancesAsButton";
import DeleteTaskInstanceButton from "./DeleteTaskInstanceButton";
+import TaskInstanceNoteButton from "./TaskInstanceNoteButton";
import { TaskInstancesFilter } from "./TaskInstancesFilter";
type TaskInstanceRow = { row: { original: TaskInstanceResponse } };
@@ -235,6 +236,7 @@ const taskInstanceColumns = ({
accessorKey: "actions",
cell: ({ row }) => (
<Flex justifyContent="end">
+ <TaskInstanceNoteButton taskInstance={row.original} />
<ClearTaskInstanceButton taskInstance={row.original} />
<MarkTaskInstanceAsButton taskInstance={row.original} />
<DeleteTaskInstanceButton taskInstance={row.original} />
diff --git a/airflow-core/src/airflow/ui/src/queries/useDagRunNote.ts
b/airflow-core/src/airflow/ui/src/queries/useDagRunNote.ts
new file mode 100644
index 00000000000..5d821d44fb2
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useDagRunNote.ts
@@ -0,0 +1,41 @@
+/*!
+ * 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 { DAGRunResponse } from "openapi/requests/types.gen";
+
+import { useNoteEditor } from "./useNoteEditor";
+import { usePatchDagRun } from "./usePatchDagRun";
+
+/**
+ * Note-editing state and save logic for a Dag run.
+ * Used by both the detail-page NotePreview and the list-page RunNoteButton
+ * so mutation + cache invalidation stays in one place.
+ */
+export const useDagRunNote = (dagRun: DAGRunResponse) => {
+ const { isPending, mutate } = usePatchDagRun({
+ dagId: dagRun.dag_id,
+ dagRunId: dagRun.dag_run_id,
+ });
+
+ return useNoteEditor({
+ isPending,
+ mutateNote: (note, options) =>
+ mutate({ dagId: dagRun.dag_id, dagRunId: dagRun.dag_run_id, requestBody:
{ note } }, options),
+ savedNote: dagRun.note,
+ });
+};
diff --git a/airflow-core/src/airflow/ui/src/queries/useNoteEditor.ts
b/airflow-core/src/airflow/ui/src/queries/useNoteEditor.ts
new file mode 100644
index 00000000000..5a702890df0
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useNoteEditor.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 { useState } from "react";
+
+/**
+ * Shared note-editing state and save logic, parameterised over the underlying
+ * mutation. Keeps the local-state / diff-on-save / reset-on-open behaviour in
+ * one place for the Dag run and task instance note hooks.
+ */
+export const useNoteEditor = ({
+ isPending,
+ mutateNote,
+ savedNote,
+}: {
+ readonly isPending: boolean;
+ readonly mutateNote: (note: string | null, options: { onError: () => void })
=> void;
+ readonly savedNote: string | null;
+}) => {
+ const [note, setNote] = useState<string | null>(savedNote);
+
+ const onSave = () => {
+ if (note !== savedNote) {
+ mutateNote(note, { onError: () => setNote(savedNote ?? null) });
+ }
+ };
+
+ // Reset local state to the server value each time an edit surface is opened,
+ // so stale edits from a previous session don't linger.
+ const onOpen = () => setNote(savedNote ?? "");
+
+ return { isPending, note, onOpen, onSave, setNote };
+};
diff --git a/airflow-core/src/airflow/ui/src/queries/useTaskInstanceNote.ts
b/airflow-core/src/airflow/ui/src/queries/useTaskInstanceNote.ts
new file mode 100644
index 00000000000..06f518914cb
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useTaskInstanceNote.ts
@@ -0,0 +1,52 @@
+/*!
+ * 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 { TaskInstanceResponse } from "openapi/requests/types.gen";
+
+import { useNoteEditor } from "./useNoteEditor";
+import { usePatchTaskInstance } from "./usePatchTaskInstance";
+
+/**
+ * Note-editing state and save logic for a task instance.
+ * Used by both the detail-page NotePreview and the list-page
TaskInstanceNoteButton
+ * so mutation + cache invalidation stays in one place.
+ */
+export const useTaskInstanceNote = (taskInstance: TaskInstanceResponse) => {
+ const { isPending, mutate } = usePatchTaskInstance({
+ dagId: taskInstance.dag_id,
+ dagRunId: taskInstance.dag_run_id,
+ mapIndex: taskInstance.map_index,
+ taskId: taskInstance.task_id,
+ });
+
+ return useNoteEditor({
+ isPending,
+ mutateNote: (note, options) =>
+ mutate(
+ {
+ dagId: taskInstance.dag_id,
+ dagRunId: taskInstance.dag_run_id,
+ mapIndex: taskInstance.map_index,
+ requestBody: { note },
+ taskId: taskInstance.task_id,
+ },
+ options,
+ ),
+ savedNote: taskInstance.note,
+ });
+};