inside a
breaks hydration).
+ const match = /language-(?\w+)/u.exec(className ?? "");
+
+ if (!match) {
return (
{children}
@@ -126,9 +123,7 @@ const createCodeComponent =
);
}
- // Extract language from className (format: "language-python")
- const match = /language-(?\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 ;
+ return (
+
+ );
};
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 d448a181bbc24..b10d4b7891038 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(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}
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 4443ec5305649..971354ebe55b5 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 (
+
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 0000000000000..f8d5c732866ad
--- /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 (
+
+ );
+};
+
+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 18c0e92742642..d9a56dd796db6 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(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 (
-
@@ -154,6 +125,14 @@ export const Header = ({ dagRun }: { readonly dagRun: DAGRunResponse }) => {
]}
title={dagRun.dag_run_id}
/>
+
);
};
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 d053c68534c10..48364a9e9a678 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(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
-
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}]` : ""}`}
/>
+
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 0000000000000..58d1faa79e91b
--- /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 (
+
+ );
+};
+
+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 ff28b5eb7b53b..3249b87662150 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 }) => (
+
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 0000000000000..5d821d44fb266
--- /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 0000000000000..5a702890df0e3
--- /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(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 0000000000000..06f518914cbe3
--- /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,
+ });
+};