This is an automated email from the ASF dual-hosted git repository.
bbovenzi 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 e6b1b2019d7 Create asset event modal (#47421)
e6b1b2019d7 is described below
commit e6b1b2019d781abe9efebc98932233089ca6c96f
Author: Brent Bovenzi <[email protected]>
AuthorDate: Thu Mar 6 11:57:09 2025 -0500
Create asset event modal (#47421)
* Creat asset modal
* Invalidate queries based on success response
* Add JsonEditor ref, fix typo, and add check if multiple upstream dags
* dont mount modal if not open
---
.../components/FlexibleForm/FieldAdvancedArray.tsx | 27 +--
.../ui/src/components/FlexibleForm/FieldObject.tsx | 30 +---
airflow/ui/src/components/JsonEditor.tsx | 51 ++++++
.../src/components/TriggerDag/TriggerDAGForm.tsx | 25 +--
airflow/ui/src/pages/Asset/Asset.tsx | 5 +-
airflow/ui/src/pages/Asset/CreateAssetEvent.tsx | 54 ++++++
.../ui/src/pages/Asset/CreateAssetEventModal.tsx | 195 +++++++++++++++++++++
7 files changed, 313 insertions(+), 74 deletions(-)
diff --git a/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
b/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
index ef3e7dfdc1c..cb4760b4df2 100644
--- a/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
+++ b/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
@@ -17,18 +17,13 @@
* under the License.
*/
import { Text } from "@chakra-ui/react";
-import { json } from "@codemirror/lang-json";
-import { githubLight, githubDark } from "@uiw/codemirror-themes-all";
-import CodeMirror from "@uiw/react-codemirror";
import { useState } from "react";
-import { useColorMode } from "src/context/colorMode";
-
import type { FlexibleFormElementProps } from ".";
+import { JsonEditor } from "../JsonEditor";
import { paramPlaceholder, useParamStore } from "../TriggerDag/useParamStore";
export const FieldAdvancedArray = ({ name }: FlexibleFormElementProps) => {
- const { colorMode } = useColorMode();
const { paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
const [error, setError] = useState<unknown>(undefined);
@@ -76,29 +71,13 @@ export const FieldAdvancedArray = ({ name }:
FlexibleFormElementProps) => {
return (
<>
- <CodeMirror
- basicSetup={{
- autocompletion: true,
- bracketMatching: true,
- foldGutter: true,
- lineNumbers: true,
- }}
- extensions={[json()]}
- height="200px"
+ <JsonEditor
id={`element_${name}`}
onChange={handleChange}
- style={{
- border: "1px solid var(--chakra-colors-border)",
- borderRadius: "8px",
- outline: "none",
- padding: "2px",
- width: "100%",
- }}
- theme={colorMode === "dark" ? githubDark : githubLight}
value={JSON.stringify(param.value ?? [], undefined, 2)}
/>
{Boolean(error) ? (
- <Text color="red.solid" fontSize="xs">
+ <Text color="fg.error" fontSize="xs">
{String(error)}
</Text>
) : undefined}
diff --git a/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
b/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
index 3e577a3666c..c813882adb1 100644
--- a/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
+++ b/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
@@ -17,19 +17,13 @@
* under the License.
*/
import { Text } from "@chakra-ui/react";
-import { json } from "@codemirror/lang-json";
-import { githubLight, githubDark } from "@uiw/codemirror-themes-all";
-import CodeMirror from "@uiw/react-codemirror";
import { useState } from "react";
-import { useColorMode } from "src/context/colorMode";
-
import type { FlexibleFormElementProps } from ".";
+import { JsonEditor } from "../JsonEditor";
import { paramPlaceholder, useParamStore } from "../TriggerDag/useParamStore";
export const FieldObject = ({ name }: FlexibleFormElementProps) => {
- const { colorMode } = useColorMode();
-
const { paramsDict, setParamsDict } = useParamStore();
const param = paramsDict[name] ?? paramPlaceholder;
const [error, setError] = useState<unknown>(undefined);
@@ -53,28 +47,12 @@ export const FieldObject = ({ name }:
FlexibleFormElementProps) => {
return (
<>
- <CodeMirror
- basicSetup={{
- autocompletion: true,
- bracketMatching: true,
- foldGutter: true,
- lineNumbers: true,
- }}
- extensions={[json()]}
- height="200px"
+ <JsonEditor
id={`element_${name}`}
onChange={handleChange}
- style={{
- border: "1px solid var(--chakra-colors-border)",
- borderRadius: "8px",
- outline: "none",
- padding: "2px",
- width: "100%",
- }}
- theme={colorMode === "dark" ? githubDark : githubLight}
- value={JSON.stringify(param.value ?? {}, undefined, 2)}
+ value={JSON.stringify(param.value ?? [], undefined, 2)}
/>
- {Boolean(error) ? <Text color="red">{String(error)}</Text> : undefined}
+ {Boolean(error) ? <Text color="fg,.error">{String(error)}</Text> :
undefined}
</>
);
};
diff --git a/airflow/ui/src/components/JsonEditor.tsx
b/airflow/ui/src/components/JsonEditor.tsx
new file mode 100644
index 00000000000..e433a386049
--- /dev/null
+++ b/airflow/ui/src/components/JsonEditor.tsx
@@ -0,0 +1,51 @@
+/*!
+ * 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 { json } from "@codemirror/lang-json";
+import { githubLight, githubDark } from "@uiw/codemirror-themes-all";
+import CodeMirror, { type ReactCodeMirrorProps, type ReactCodeMirrorRef } from
"@uiw/react-codemirror";
+import { forwardRef } from "react";
+
+import { useColorMode } from "src/context/colorMode";
+
+export const JsonEditor = forwardRef<ReactCodeMirrorRef,
ReactCodeMirrorProps>((props, ref) => {
+ const { colorMode } = useColorMode();
+
+ return (
+ <CodeMirror
+ basicSetup={{
+ autocompletion: true,
+ bracketMatching: true,
+ foldGutter: true,
+ lineNumbers: true,
+ }}
+ extensions={[json()]}
+ height="200px"
+ ref={ref}
+ style={{
+ border: "1px solid var(--chakra-colors-border)",
+ borderRadius: "8px",
+ outline: "none",
+ padding: "2px",
+ width: "100%",
+ }}
+ theme={colorMode === "dark" ? githubDark : githubLight}
+ {...props}
+ />
+ );
+});
diff --git a/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
b/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
index 57a1149e1a2..bcb4b3d08af 100644
--- a/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
+++ b/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
@@ -17,19 +17,16 @@
* under the License.
*/
import { Input, Button, Box, Spacer, HStack, Field, Stack } from
"@chakra-ui/react";
-import { json } from "@codemirror/lang-json";
-import { githubLight, githubDark } from "@uiw/codemirror-themes-all";
-import CodeMirror from "@uiw/react-codemirror";
import { useEffect, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { FiPlay } from "react-icons/fi";
-import { useColorMode } from "src/context/colorMode";
import { useDagParams } from "src/queries/useDagParams";
import { useTrigger } from "src/queries/useTrigger";
import { ErrorAlert } from "../ErrorAlert";
import { FlexibleForm, flexibleFormDefaultSection } from "../FlexibleForm";
+import { JsonEditor } from "../JsonEditor";
import { Accordion } from "../ui";
import EditableMarkdown from "./EditableMarkdown";
import { useParamStore } from "./useParamStore";
@@ -102,8 +99,6 @@ const TriggerDAGForm = ({ dagId, onClose, open }:
TriggerDAGFormProps) => {
setErrors((prev) => ({ ...prev, date: undefined }));
};
- const { colorMode } = useColorMode();
-
return (
<>
<Accordion.Root
@@ -166,27 +161,11 @@ const TriggerDAGForm = ({ dagId, onClose, open }:
TriggerDAGFormProps) => {
render={({ field }) => (
<Field.Root invalid={Boolean(errors.conf)} mt={6}>
<Field.Label fontSize="md">Configuration JSON</Field.Label>
- <CodeMirror
+ <JsonEditor
{...field}
- basicSetup={{
- autocompletion: true,
- bracketMatching: true,
- foldGutter: true,
- lineNumbers: true,
- }}
- extensions={[json()]}
- height="200px"
onBlur={() => {
field.onChange(validateAndPrettifyJson(field.value));
}}
- style={{
- border: "1px solid var(--chakra-colors-border)",
- borderRadius: "8px",
- outline: "none",
- padding: "2px",
- width: "100%",
- }}
- theme={colorMode === "dark" ? githubDark : githubLight}
/>
{Boolean(errors.conf) ?
<Field.ErrorText>{errors.conf}</Field.ErrorText> : undefined}
</Field.Root>
diff --git a/airflow/ui/src/pages/Asset/Asset.tsx
b/airflow/ui/src/pages/Asset/Asset.tsx
index 60401907e0d..a34ad423537 100644
--- a/airflow/ui/src/pages/Asset/Asset.tsx
+++ b/airflow/ui/src/pages/Asset/Asset.tsx
@@ -24,9 +24,10 @@ import { useParams } from "react-router-dom";
import { useAssetServiceGetAsset } from "openapi/queries";
import { AssetEvents } from "src/components/Assets/AssetEvents";
import { BreadcrumbStats } from "src/components/BreadcrumbStats";
-import { ProgressBar } from "src/components/ui";
+import { ProgressBar, Toaster } from "src/components/ui";
import { AssetGraph } from "./AssetGraph";
+import { CreateAssetEvent } from "./CreateAssetEvent";
import { Header } from "./Header";
export const Asset = () => {
@@ -50,8 +51,10 @@ export const Asset = () => {
return (
<ReactFlowProvider>
+ <Toaster />
<HStack justifyContent="space-between" mb={2}>
<BreadcrumbStats links={links} />
+ <CreateAssetEvent asset={asset} />
</HStack>
<ProgressBar size="xs" visibility={Boolean(isLoading) ? "visible" :
"hidden"} />
<Box flex={1} minH={0}>
diff --git a/airflow/ui/src/pages/Asset/CreateAssetEvent.tsx
b/airflow/ui/src/pages/Asset/CreateAssetEvent.tsx
new file mode 100644
index 00000000000..6310e6de50d
--- /dev/null
+++ b/airflow/ui/src/pages/Asset/CreateAssetEvent.tsx
@@ -0,0 +1,54 @@
+/*!
+ * 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 { useDisclosure } from "@chakra-ui/react";
+import { FiPlay } from "react-icons/fi";
+
+import type { AssetResponse } from "openapi/requests/types.gen";
+import ActionButton from "src/components/ui/ActionButton";
+
+import { CreateAssetEventModal } from "./CreateAssetEventModal";
+
+type Props = {
+ readonly asset?: AssetResponse;
+ readonly withText?: boolean;
+};
+
+export const CreateAssetEvent = ({ asset, withText = true }: Props) => {
+ const { onClose, onOpen, open } = useDisclosure();
+
+ return (
+ <Box>
+ <ActionButton
+ actionName="Create Asset Event"
+ colorPalette="blue"
+ disabled={asset === undefined}
+ icon={<FiPlay />}
+ onClick={onOpen}
+ text="Create Asset Event"
+ variant="solid"
+ withText={withText}
+ />
+
+ {asset === undefined || !open ? undefined : (
+ <CreateAssetEventModal asset={asset} onClose={onClose} open={open} />
+ )}
+ </Box>
+ );
+};
diff --git a/airflow/ui/src/pages/Asset/CreateAssetEventModal.tsx
b/airflow/ui/src/pages/Asset/CreateAssetEventModal.tsx
new file mode 100644
index 00000000000..18a9046b8c9
--- /dev/null
+++ b/airflow/ui/src/pages/Asset/CreateAssetEventModal.tsx
@@ -0,0 +1,195 @@
+/*!
+ * 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 { Button, Field, Heading, HStack, VStack, Text } from
"@chakra-ui/react";
+import { useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { FiPlay } from "react-icons/fi";
+
+import {
+ useAssetServiceCreateAssetEvent,
+ UseAssetServiceGetAssetEventsKeyFn,
+ useAssetServiceMaterializeAsset,
+ UseDagRunServiceGetDagRunsKeyFn,
+ useDagsServiceRecentDagRunsKey,
+ useDependenciesServiceGetDependencies,
+ UseGridServiceGridDataKeyFn,
+ UseTaskInstanceServiceGetTaskInstancesKeyFn,
+} from "openapi/queries";
+import type {
+ AssetEventResponse,
+ AssetResponse,
+ DAGRunResponse,
+ EdgeResponse,
+} from "openapi/requests/types.gen";
+import { ErrorAlert } from "src/components/ErrorAlert";
+import { JsonEditor } from "src/components/JsonEditor";
+import { Dialog, toaster } from "src/components/ui";
+import { RadioCardItem, RadioCardRoot } from "src/components/ui/RadioCard";
+
+type Props = {
+ readonly asset: AssetResponse;
+ readonly onClose: () => void;
+ readonly open: boolean;
+};
+
+export const CreateAssetEventModal = ({ asset, onClose, open }: Props) => {
+ const [eventType, setEventType] = useState("manual");
+ const [extraError, setExtraError] = useState<string | undefined>();
+ const [extra, setExtra] = useState("{}");
+ const queryClient = useQueryClient();
+
+ const { data } = useDependenciesServiceGetDependencies({ nodeId:
`asset:${asset.name}` }, undefined, {
+ enabled: Boolean(asset) && Boolean(asset.name),
+ });
+
+ const upstreamDags: Array<EdgeResponse> = (data?.edges ?? []).filter(
+ (edge) => edge.target_id === `asset:${asset.name}` &&
edge.source_id.startsWith("dag:"),
+ );
+ const hasUpstreamDag = upstreamDags.length === 1;
+ const [upstreamDag] = upstreamDags;
+ const upstreamDagId = hasUpstreamDag ?
upstreamDag?.source_id.replace("dag:", "") : undefined;
+
+ // TODO move validate + prettify into JsonEditor
+ const validateAndPrettifyJson = (newValue: string) => {
+ try {
+ const parsedJson = JSON.parse(newValue) as JSON;
+
+ setExtraError(undefined);
+
+ const formattedJson = JSON.stringify(parsedJson, undefined, 2);
+
+ if (formattedJson !== extra) {
+ setExtra(formattedJson); // Update only if the value is different
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : "Unknown
error occurred.";
+
+ setExtraError(errorMessage);
+ }
+ };
+
+ const onSuccess = async (response: AssetEventResponse | DAGRunResponse) => {
+ setExtra("{}");
+ setExtraError(undefined);
+ onClose();
+
+ let queryKeys = [UseAssetServiceGetAssetEventsKeyFn({ assetId: asset.id },
[{ assetId: asset.id }])];
+
+ if ("dag_run_id" in response) {
+ const dagId = response.dag_id;
+
+ queryKeys = [
+ ...queryKeys,
+ [useDagsServiceRecentDagRunsKey],
+ UseDagRunServiceGetDagRunsKeyFn({ dagId }, [{ dagId }]),
+ UseTaskInstanceServiceGetTaskInstancesKeyFn({ dagId, dagRunId: "~" },
[{ dagId, dagRunId: "~" }]),
+ UseGridServiceGridDataKeyFn({ dagId }, [{ dagId }]),
+ ];
+
+ toaster.create({
+ description: `Upstream Dag ${response.dag_id} was triggered
successfully.`,
+ title: "Materializing Asset",
+ type: "success",
+ });
+ } else {
+ toaster.create({
+ description: "Manual asset event creation was successful.",
+ title: "Asset Event Created",
+ type: "success",
+ });
+ }
+
+ await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({
queryKey: key })));
+ };
+
+ const {
+ error: manualError,
+ isPending,
+ mutate: createAssetEvent,
+ } = useAssetServiceCreateAssetEvent({ onSuccess });
+ const {
+ error: materializeError,
+ isPending: isMaterializePending,
+ mutate: materializeAsset,
+ } = useAssetServiceMaterializeAsset({
+ onSuccess,
+ });
+
+ const handleSubmit = () => {
+ if (eventType === "materialize") {
+ materializeAsset({ assetId: asset.id });
+ } else {
+ createAssetEvent({
+ requestBody: { asset_id: asset.id, extra: JSON.parse(extra) as
Record<string, unknown> },
+ });
+ }
+ };
+
+ return (
+ <Dialog.Root lazyMount onOpenChange={onClose} open={open} size="xl"
unmountOnExit>
+ <Dialog.Content backdrop>
+ <Dialog.Header paddingBottom={0}>
+ <VStack align="start" gap={4}>
+ <Heading size="xl">Create Asset Event for {asset.name}</Heading>
+ </VStack>
+ </Dialog.Header>
+
+ <Dialog.CloseTrigger />
+
+ <Dialog.Body>
+ <RadioCardRoot
+ mb={6}
+ onChange={(event) => {
+ setEventType((event.target as HTMLInputElement).value);
+ }}
+ value={eventType}
+ >
+ <HStack align="stretch">
+ <RadioCardItem
+ description={`Trigger the Dag upstream of this
asset${upstreamDagId === undefined ? "" : `: ${upstreamDagId}`}`}
+ disabled={!hasUpstreamDag}
+ label="Materialize"
+ value="materialize"
+ />
+ <RadioCardItem description="Directly create an Asset Event"
label="Manual" value="manual" />
+ </HStack>
+ </RadioCardRoot>
+ {eventType === "manual" ? (
+ <Field.Root mt={6}>
+ <Field.Label fontSize="md">Asset Event Extra</Field.Label>
+ <JsonEditor onChange={validateAndPrettifyJson} value={extra} />
+ <Text color="fg.error">{extraError}</Text>
+ </Field.Root>
+ ) : undefined}
+ <ErrorAlert error={eventType === "manual" ? manualError :
materializeError} />
+ </Dialog.Body>
+ <Dialog.Footer>
+ <Button
+ colorPalette="blue"
+ disabled={Boolean(extraError)}
+ loading={isPending || isMaterializePending}
+ onClick={handleSubmit}
+ >
+ <FiPlay /> Create Event
+ </Button>
+ </Dialog.Footer>
+ </Dialog.Content>
+ </Dialog.Root>
+ );
+};