This is an automated email from the ASF dual-hosted git repository.

pierrejeambrun pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new d721c9b8258 [v3-4-test] Add a Duplicate action to the Connections list 
(#75110) (#75143)
d721c9b8258 is described below

commit d721c9b825870df6a2d743576bfc801a117d2d90
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Oct 20 00:36:12 2026 +0200

    [v3-4-test] Add a Duplicate action to the Connections list (#75110) (#75143)
    
    Setting up near-identical connections for several environments meant
    re-typing every field by hand; Airflow 2 offered duplication and the
    Airflow 3 UI dropped it.
    
    The API returns secrets as '***'. Editing merges that placeholder back
    into the stored secret, but creating has nothing to merge with, so
    copying it would save the placeholder itself as the new secret. Redacted
    values are therefore left empty for the user to fill in.
    (cherry picked from commit f20ff2cfa7a162dd1dc187447ee14dfe00cb1b00)
    
    
    closes: #74998
    
    Co-authored-by: Pierre Jeambrun <[email protected]>
---
 .../airflow/ui/public/i18n/locales/en/admin.json   |   2 +
 .../ui/src/pages/Connections/Connections.tsx       |   2 +
 .../Connections/DuplicateConnectionButton.test.tsx | 114 +++++++++++++++++++++
 .../Connections/DuplicateConnectionButton.tsx      | 101 ++++++++++++++++++
 4 files changed, 219 insertions(+)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
index e8735ea2713..fc4f94675ba 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
@@ -30,6 +30,7 @@
       "firstConfirmMessage_other": "You are about to delete the following 
connections:",
       "title": "Delete $t(connections.connection_one)"
     },
+    "duplicate": "Duplicate $t(connections.connection_one)",
     "edit": "Edit $t(connections.connection_one)",
     "form": {
       "connectionIdRequired": "Connection ID is required",
@@ -38,6 +39,7 @@
       "connectionTypeRequired": "Connection Type is required",
       "extraFieldsJson": "Extra Fields JSON",
       "helperText": "Connection type missing? Make sure you have installed the 
corresponding Airflow Providers Package.",
+      "helperTextForDuplicatedRedactedFields": "Redacted fields ('***') are 
not copied. Fill them in for the new connection.",
       "helperTextForRedactedFields": "Redacted fields ('***') will remain 
unchanged if not modified.",
       "selectConnectionType": "Select Connection Type",
       "standardFields": "Standard Fields"
diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx 
b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
index 3d0f199c575..ee19f8c0001 100644
--- a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
@@ -52,6 +52,7 @@ import { formatNumber, useDocumentTitle } from "src/utils";
 import AddConnectionButton from "./AddConnectionButton";
 import DeleteConnectionButton from "./DeleteConnectionButton";
 import DeleteConnectionsButton from "./DeleteConnectionsButton";
+import DuplicateConnectionButton from "./DuplicateConnectionButton";
 import EditConnectionButton from "./EditConnectionButton";
 import { NothingFoundInfo } from "./NothingFoundInfo";
 import TestConnectionButton from "./TestConnectionButton";
@@ -125,6 +126,7 @@ const getColumns = ({
         <Flex justifyContent="end">
           <TestConnectionButton connection={original} />
           <EditConnectionButton connection={original} disabled={hasSelection} 
/>
+          <DuplicateConnectionButton connection={original} 
disabled={hasSelection} />
           <DeleteConnectionButton connectionId={original.connection_id} 
disabled={hasSelection} />
         </Flex>
       ),
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.test.tsx
new file mode 100644
index 00000000000..61ca4e0c75f
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.test.tsx
@@ -0,0 +1,114 @@
+/*!
+ * 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/vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ConnectionResponse } from "openapi/requests/types.gen";
+
+import i18n from "src/i18n/config";
+import { Wrapper } from "src/utils/Wrapper";
+
+import adminLocale from "../../../public/i18n/locales/en/admin.json";
+import DuplicateConnectionButton from "./DuplicateConnectionButton";
+
+const { mockAddConnection } = vi.hoisted(() => ({ mockAddConnection: vi.fn() 
}));
+
+vi.mock("src/queries/useAddConnection", () => ({
+  useAddConnection: () => ({ addConnection: mockAddConnection, error: 
undefined, isPending: false }),
+}));
+
+vi.mock("src/queries/useConnectionTypeMeta", () => ({
+  useConnectionTypeMeta: () => ({
+    formattedData: {},
+    hookNames: { postgres: "Postgres" },
+    isPending: false,
+    keysList: ["postgres"],
+  }),
+}));
+
+vi.mock("src/queries/useConfig.tsx", () => ({
+  useConfig: () => false,
+}));
+
+const baseConnection: ConnectionResponse = {
+  conn_type: "postgres",
+  connection_id: "prod_db",
+  description: "Production",
+  extra: null,
+  host: "db.example.com",
+  login: "admin",
+  password: null,
+  port: 5432,
+  schema: "public",
+  team_name: null,
+};
+
+const duplicate = async (connection: ConnectionResponse) => {
+  render(<DuplicateConnectionButton connection={connection} disabled={false} 
/>, { wrapper: Wrapper });
+  fireEvent.click(screen.getByRole("button", { name: "Duplicate Connection" 
}));
+
+  const connectionIdInput = await screen.findByRole("textbox", { name: 
/Connection ID/u });
+
+  fireEvent.change(connectionIdInput, { target: { value: "staging_db" } });
+  fireEvent.blur(connectionIdInput);
+  const save = screen.getByRole("button", { name: /Save/u });
+
+  await waitFor(() => expect(save).toBeEnabled());
+  fireEvent.click(save);
+
+  await waitFor(() => expect(mockAddConnection).toHaveBeenCalledOnce());
+
+  return mockAddConnection.mock.calls[0]?.[0] as Record<string, string>;
+};
+
+describe("DuplicateConnectionButton", () => {
+  beforeEach(() => {
+    i18n.addResourceBundle("en", "admin", adminLocale, true, true);
+    mockAddConnection.mockClear();
+  });
+
+  it("creates a connection under the new id with the copied values", async () 
=> {
+    const body = await duplicate(baseConnection);
+
+    expect(body).toMatchObject({
+      conn_type: "postgres",
+      connection_id: "staging_db",
+      description: "Production",
+      host: "db.example.com",
+      login: "admin",
+      password: "",
+      port: "5432",
+      schema: "public",
+    });
+    expect(screen.queryByText(/are not copied/u)).not.toBeInTheDocument();
+  });
+
+  it("does not copy redacted values", async () => {
+    const body = await duplicate({
+      ...baseConnection,
+      extra: JSON.stringify({ nested: { api_key: "***", keep: "x" }, region: 
"eu", token: "***" }),
+      password: "***",
+    });
+
+    expect(body.password).toBe("");
+    expect(JSON.parse(body.extra ?? "")).toEqual({ nested: { keep: "x" }, 
region: "eu" });
+    expect(screen.getByText(/are not copied/u)).toBeInTheDocument();
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx
 
b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx
new file mode 100644
index 00000000000..e490acecd02
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx
@@ -0,0 +1,101 @@
+/*!
+ * 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 { useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiCopy } from "react-icons/fi";
+
+import type { ConnectionResponse } from "openapi/requests/types.gen";
+
+import { Alert, IconButton, Modal } from "src/system-components";
+
+import { useAddConnection } from "src/queries/useAddConnection";
+
+import ConnectionForm from "./ConnectionForm";
+import type { ConnectionBody } from "./Connections";
+
+// The API returns sensitive values as this placeholder. Editing merges it 
back into the stored secret,
+// but a new connection has no stored secret, so the placeholder must not be 
submitted as a value.
+const REDACTED = "***";
+
+type Props = {
+  readonly connection: ConnectionResponse;
+  readonly disabled: boolean;
+};
+
+const DuplicateConnectionButton = ({ connection, disabled }: Props) => {
+  const { t: translate } = useTranslation("admin");
+  const { onClose, onOpen, open } = useDisclosure();
+  const { addConnection, error, isPending } = useAddConnection({ 
onSuccessConfirm: onClose });
+
+  let hasRedactedValues = connection.password === REDACTED;
+  const extra =
+    connection.extra === "" || connection.extra === null
+      ? "{}"
+      : JSON.stringify(
+          JSON.parse(connection.extra, (_key, value: unknown) => {
+            if (value === REDACTED) {
+              hasRedactedValues = true;
+
+              return undefined;
+            }
+
+            return value;
+          }),
+        );
+
+  const initialConnection: ConnectionBody = {
+    conn_type: connection.conn_type,
+    connection_id: "",
+    description: connection.description ?? "",
+    extra,
+    host: connection.host ?? "",
+    login: connection.login ?? "",
+    password: connection.password === REDACTED ? "" : (connection.password ?? 
""),
+    port: connection.port?.toString() ?? "",
+    schema: connection.schema ?? "",
+    team_name: connection.team_name ?? "",
+  };
+
+  return (
+    <>
+      <IconButton disabled={disabled} 
label={translate("connections.duplicate")} onClick={onOpen}>
+        <FiCopy />
+      </IconButton>
+      <Modal
+        lazyMount
+        onOpenChange={onClose}
+        open={open}
+        title={translate("connections.duplicate")}
+        unmountOnExit
+      >
+        {hasRedactedValues ? (
+          <Alert 
status="info">{translate("connections.form.helperTextForDuplicatedRedactedFields")}</Alert>
+        ) : undefined}
+        <ConnectionForm
+          error={error}
+          initialConnection={initialConnection}
+          isPending={isPending}
+          mutateConnection={addConnection}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default DuplicateConnectionButton;

Reply via email to