Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions airflow-core/src/airflow/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.8",
"@types/debounce-promise": "^3.1.9",
"@types/react-resizable": "^3.0.8",
"@uiw/codemirror-themes-all": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12",
"@visx/group": "^3.12.0",
Expand Down Expand Up @@ -56,6 +57,7 @@
"react-innertext": "^1.1.5",
"react-json-view": "^1.21.3",
"react-markdown": "^9.1.0",
"react-resizable": "^3.0.5",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "^6.30.0",
"react-syntax-highlighter": "^15.6.1",
Expand Down
45 changes: 45 additions & 0 deletions airflow-core/src/airflow/ui/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ import { Box, Heading, VStack } from "@chakra-ui/react";
import { type ReactElement, useState } from "react";

import { Button, Dialog } from "src/components/ui";
import { ResizableWrapper } from "src/components/ui/ResizableWrapper";

import ReactMarkdown from "./ReactMarkdown";

const STORAGE_KEY = "airflow-markdown-dialog-size";

const DisplayMarkdownButton = ({
header,
icon,
Expand All @@ -48,14 +51,24 @@ const DisplayMarkdownButton = ({
open={isDocsOpen}
size="md"
>
<Dialog.Content backdrop>
<Dialog.Header bg="info.muted">
<Heading size="xl">{header}</Heading>
<Dialog.CloseTrigger closeButtonProps={{ size: "xl" }} />
</Dialog.Header>
<Dialog.Body alignItems="flex-start" as={VStack} gap="0">
<ReactMarkdown>{mdContent}</ReactMarkdown>
</Dialog.Body>
<Dialog.Content
backdrop
style={{
maxHeight: "none",
maxWidth: "none",
padding: 0,
width: "auto",
}}
>
<ResizableWrapper storageKey={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="auto">
<ReactMarkdown>{mdContent}</ReactMarkdown>
</Dialog.Body>
</ResizableWrapper>
</Dialog.Content>
</Dialog.Root>
</Box>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*!
* 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 } from "@chakra-ui/react";
import { forwardRef } from "react";
import type { ReactNode } from "react";
import { ResizableBox } from "react-resizable";
import "react-resizable/css/styles.css";

import { usePersistentResizableState } from "src/utils/usePersistentResizableState";

const ResizeHandle = forwardRef<HTMLDivElement>((props, ref) => (
<Box
_before={{
background:
"linear-gradient(-45deg, transparent 6px, #ccc 6px, #ccc 8px, transparent 8px, transparent 12px, #ccc 12px, #ccc 14px, transparent 14px)",
bottom: 0,
content: '""',
height: "100%",
position: "absolute",
right: 0,
width: "100%",
}}
bottom={0}
cursor="se-resize"
height="20px"
position="absolute"
ref={ref}
right={0}
width="20px"
{...props}
/>
));

type ResizableWrapperProps = {
readonly children: ReactNode;
readonly defaultSize?: { height: number; width: number };
readonly maxConstraints?: [number, number];
readonly storageKey: string;
};

const DEFAULT_SIZE = { height: 400, width: 500 };
const DEFAULT_MAX: [number, number] = [1200, 800];

export const ResizableWrapper = ({
children,
defaultSize = DEFAULT_SIZE,
maxConstraints = DEFAULT_MAX,
storageKey,
}: ResizableWrapperProps) => {
const { handleResize, handleResizeStop, size } = usePersistentResizableState(storageKey, defaultSize);

return (
<ResizableBox
handle={<ResizeHandle />}
height={size.height}
maxConstraints={maxConstraints}
minConstraints={[DEFAULT_SIZE.width, DEFAULT_SIZE.height]}
onResize={handleResize}
onResizeStop={handleResizeStop}
resizeHandles={["se"]}
style={{
backgroundColor: "inherit",
borderRadius: "inherit",
overflow: "hidden",
position: "relative",
}}
width={size.width}
>
{children}
</ResizableBox>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*!
* 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 { useCallback, useState } from "react";

type Size = { height: number; width: number };

const getInitialSize = (key: string, defaultSize: Size): Size => {
try {
const item = localStorage.getItem(key);

if (item === null) {
return defaultSize;
}

const parsed: unknown = JSON.parse(item);

if (
typeof parsed === "object" &&
parsed !== null &&
"width" in parsed &&
"height" in parsed &&
typeof parsed.width === "number" &&
typeof parsed.height === "number"
) {
return { height: parsed.height, width: parsed.width };
}
} catch {
// Ignore parsing errors
}

return defaultSize;
};

export const usePersistentResizableState = (storageKey: string, defaultSize: Size) => {
const [size, setSize] = useState(() => getInitialSize(storageKey, defaultSize));

const handleResize = useCallback((_event: React.SyntheticEvent, { size: newSize }: { size: Size }) => {
setSize(newSize);
}, []);

const handleResizeStop = useCallback(
(_event: React.SyntheticEvent, { size: finalSize }: { size: Size }) => {
try {
localStorage.setItem(storageKey, JSON.stringify(finalSize));
} catch {
// Ignore storage errors
}
},
[storageKey],
);

return { handleResize, handleResizeStop, size };
};