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 32f9988a23963..3aaa5115bb2f5 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 3b467734a09eb..0000000000000 --- 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 ( - - ) => { - const { value } = event.target; - - setCurrentValue(value); - setMdContent(value); - }} - value={currentValue} - > - - {Boolean(currentValue) ? ( - {currentValue} - ) : ( - {placeholder} - )} - - - - - ); -}; - -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 7d2f5e61c8e15..e75f4daa2757c 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 ? : ; const label = hasContent ? translate("note.label") : translate("note.add"); const handleOpen = () => { @@ -60,47 +57,18 @@ const EditableMarkdownButton = ({ return ( <> - {noteIcon} + - setIsOpen(false)} - open={isOpen} - size="md" - unmountOnExit={true} - > - - - - {header} - - - - - - - - - - - - - - - + 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 7908076bc4d9e..bb4ad874d168b 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 ( - + 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 0000000000000..383b0c7d9c173 --- /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 = {}) => + render(, { 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 ; + }; + + render(, { 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 0000000000000..80288a37d3fa4 --- /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 ? ( + {mdContent ?? ""} + ) : ( + {placeholder} + ); + + return ( + { + if (!nextOpen) { + onDismiss(); + } + }} + open={isOpen} + size="xl" + unmountOnExit={true} + > + + + + {header} + + + + + {showEditor ? ( +