Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
31 changes: 31 additions & 0 deletions web/src/components/network/ConnectionForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ describe("ConnectionForm", () => {

it("submits with the entered values", async () => {
const { user } = installerRender(<ConnectionForm />);
await user.clear(screen.getByLabelText("Name"));
await user.type(screen.getByLabelText("Name"), "Testing Connection 1");
await user.click(screen.getByRole("button", { name: "Accept" }));
await waitFor(() =>
Expand Down Expand Up @@ -406,6 +407,36 @@ describe("ConnectionForm", () => {
it.todo("shows Advanced IPv6 when config has no method but system already has IPv6 addresses");
});

describe("Auto-generated name", () => {
it("pre-fills with the connection type when binding is Any", async () => {
installerRender(<ConnectionForm />);
expect(screen.getByLabelText("Name")).toHaveValue("Ethernet");
});

it("pre-fills with type_device when binding is changed to Chosen by name", async () => {
const { user } = installerRender(<ConnectionForm />);
await user.click(screen.getByLabelText("Device"));
await user.click(screen.getByRole("option", { name: /^Chosen by name/ }));
expect(screen.getByLabelText("Name")).toHaveValue("Ethernet enp1s0");
});

it("pre-fills with type_mac when binding is changed to Chosen by MAC", async () => {
const { user } = installerRender(<ConnectionForm />);
await user.click(screen.getByLabelText("Device"));
await user.click(screen.getByRole("option", { name: /^Chosen by MAC/ }));
expect(screen.getByLabelText("Name")).toHaveValue("Ethernet 00:11:22:33:44:55");
});

it("stops auto-updating once the user manually edits the name", async () => {
const { user } = installerRender(<ConnectionForm />);
await user.clear(screen.getByLabelText("Name"));
await user.type(screen.getByLabelText("Name"), "My Connection");
await user.click(screen.getByLabelText("Device"));
await user.click(screen.getByRole("option", { name: /^Chosen by name/ }));
expect(screen.getByLabelText("Name")).toHaveValue("My Connection");
});
});

describe("DNS search domains", () => {
it("does not show the DNS search domains field by default", () => {
installerRender(<ConnectionForm />);
Expand Down
36 changes: 33 additions & 3 deletions web/src/components/network/ConnectionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* find current contact information at www.suse.com.
*/

import React from "react";
import React, { useEffect } from "react";
import { formOptions } from "@tanstack/react-form";
import { useNavigate, useParams } from "react-router";
import { isEmpty, shake } from "radashi";
Expand All @@ -43,8 +43,15 @@ import ResourceNotFound from "~/components/core/ResourceNotFound";
import IpSettings from "~/components/network/IpSettings";
import BindingModeSelector from "~/components/network/BindingModeSelector";
import DeviceSelector from "~/components/network/DeviceSelector";
import { Connection, ConnectionBindingMode, ConnectionMethod } from "~/types/network";
import {
Connection,
ConnectionBindingMode,
ConnectionMethod,
ConnectionType,
} from "~/types/network";
import { useStore } from "@tanstack/react-form";
import { useConnectionMutation, useConfig } from "~/hooks/model/config/network";
import { useConnectionName } from "~/hooks/use-connection-name";
import { useAppForm, mergeFormDefaults } from "~/hooks/form";
import { useSystem, useDevices } from "~/hooks/model/system/network";
import { extendCollection } from "~/utils";
Expand Down Expand Up @@ -298,7 +305,6 @@ function ConnectionFormContent({ defaults, isEditing = false }: ConnectionFormCo
const navigate = useNavigate();
const devices = useDevices();
const { mutateAsync: updateConnection } = useConnectionMutation();

const form = useAppForm({
...mergeFormDefaults(connectionFormOptions, {
iface: devices[0]?.name ?? "",
Expand All @@ -320,6 +326,30 @@ function ConnectionFormContent({ defaults, isEditing = false }: ConnectionFormCo
onSubmit: () => navigate(-1),
});

// Track whether the user has manually edited the name. `isDirty` is used
// instead of `isTouched` because a user could focus and blur the field
// without changing it, which would set `isTouched` but not `isDirty`.
const { bindingMode, iface, ifaceMac, nameDirty } = useStore(form.store, (s) => ({
bindingMode: s.values.bindingMode,
iface: s.values.iface,
ifaceMac: s.values.ifaceMac,
nameDirty: s.fieldMeta["name"]?.isDirty ?? false,
}));

const generatedName = useConnectionName(ConnectionType.ETHERNET, {
mode: bindingMode,
iface,
mac: ifaceMac,
});

// Keep the name in sync with the auto-generated value as long as the user
// has not manually edited it. `dontUpdateMeta` prevents `setFieldValue`
// from marking the field as dirty/touched, which would stop future updates.
useEffect(() => {
if (!isEditing && !nameDirty)
form.setFieldValue("name", generatedName, { dontUpdateMeta: true });
}, [form, isEditing, generatedName, nameDirty]);

return (
<form.AppForm>
<Form
Expand Down
11 changes: 11 additions & 0 deletions web/src/components/network/DeviceSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ function TestForm({ by }: { by: "iface" | "mac" }) {
}

describe("DeviceSelector", () => {
describe("when mounting with no device selected", () => {
it("pre-selects the first available device", async () => {
const { user } = installerRender(<TestForm by="iface" />);
await user.click(screen.getByLabelText("Device name"));
expect(screen.getByRole("option", { name: /^enp1s0/ })).toHaveAttribute(
"aria-selected",
"true",
);
});
});

describe("when by is iface", () => {
it("shows device names as options", async () => {
const { user } = installerRender(<TestForm by="iface" />);
Expand Down
11 changes: 10 additions & 1 deletion web/src/components/network/DeviceSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,17 @@ const DeviceSelector = withForm({
};
});

const listeners = {
// Pre-select the first available device when the selector mounts with no
// value, e.g. when the user switches from "Any device" binding mode.
onMount: ({ value }: { value: string }) => {
if (!value && devices.length > 0)
form.setFieldValue(name, devices[0][valueKey], { dontUpdateMeta: true });
},
};

return (
<form.AppField name={name}>
<form.AppField name={name} listeners={listeners}>
{(field) => <field.ChoiceField label={<Text srOnly>{label}</Text>} options={options} />}
</form.AppField>
);
Expand Down
81 changes: 81 additions & 0 deletions web/src/hooks/use-connection-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright (c) [2026] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation; either version 2 of the License, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

import { renderHook } from "@testing-library/react";
import { useConnectionName } from "./use-connection-name";

const mockConnections = [];

jest.mock("~/hooks/model/system/network", () => ({
useSystem: () => ({ connections: mockConnections }),
}));

describe("useConnectionName", () => {
beforeEach(() => {
mockConnections.length = 0;
});

describe("when binding mode is 'none'", () => {
it("returns only the type as the name", () => {
const { result } = renderHook(() =>
useConnectionName("ethernet", { mode: "none", iface: "enp1s0", mac: "AA:BB:CC:DD:EE:FF" }),
);
expect(result.current).toBe("Ethernet");
});
});

describe("when binding mode is 'iface'", () => {
it("returns type and name as the name", () => {
const { result } = renderHook(() =>
useConnectionName("ethernet", { mode: "iface", iface: "enp1s0", mac: "AA:BB:CC:DD:EE:FF" }),
);
expect(result.current).toBe("Ethernet enp1s0");
});
});

describe("when binding mode is 'mac'", () => {
it("returns type and MACN as the name", () => {
const { result } = renderHook(() =>
useConnectionName("ethernet", { mode: "mac", iface: "enp1s0", mac: "AA:BB:CC:DD:EE:FF" }),
);
expect(result.current).toBe("Ethernet AA:BB:CC:DD:EE:FF");
});
});

describe("when the base name is already taken", () => {
it("appends _2 as suffix", () => {
mockConnections.push({ id: "Ethernet" });
const { result } = renderHook(() =>
useConnectionName("ethernet", { mode: "none", iface: "", mac: "" }),
);
expect(result.current).toBe("Ethernet 2");
});

it("increments the suffix until a unique name is found", () => {
mockConnections.push({ id: "Ethernet" }, { id: "Ethernet 2" }, { id: "Ethernet 3" });
const { result } = renderHook(() =>
useConnectionName("ethernet", { mode: "none", iface: "", mac: "" }),
);
expect(result.current).toBe("Ethernet 4");
});
});
});
65 changes: 65 additions & 0 deletions web/src/hooks/use-connection-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright (c) [2026] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation; either version 2 of the License, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

import { title } from "radashi";
import { useSystem } from "~/hooks/model/system/network";
import { ConnectionBindingMode } from "~/types/network";

type UseConnectionNameOptions = {
mode: ConnectionBindingMode;
iface: string;
mac: string;
};

/**
* Returns a unique auto-generated connection name based on type and binding.
*
* The name follows the pattern `Type device (N)` where device is the interface
* name, the MAC address (colons stripped), or nothing when binding mode is
* "none". If the base name is already taken, a numeric suffix is appended
* starting at 2 (e.g. `Ethernet enp1s0 2`).
*
* Uniqueness is checked against the current system connections.
*/
function useConnectionName(type: string, { mode, iface, mac }: UseConnectionNameOptions): string {
const { connections } = useSystem();

const devicePartByMode: Record<ConnectionBindingMode, string> = {
none: "",
iface,
mac,
};

const typePart = title(type);
const devicePart = devicePartByMode[mode];
const baseName = devicePart ? `${typePart} ${devicePart}` : typePart;

const existing = new Set(connections.map((c) => c.id));

if (!existing.has(baseName)) return baseName;

let n = 2;
while (existing.has(`${baseName} ${n}`)) n++;
return `${baseName} ${n}`;
}

export { useConnectionName };