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>
+  );
+};

Reply via email to