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
23 changes: 23 additions & 0 deletions web/src/components/network/ConnectionForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,29 @@ describe("ConnectionForm", () => {
);
});

it("preserves the selected device when switching binding modes", async () => {
const { user } = installerRender(<ConnectionForm />);
await user.click(screen.getByLabelText("Device"));
await user.click(screen.getByRole("option", { name: /^Chosen by name/ }));
await user.click(screen.getByLabelText("Device name"));
await user.click(screen.getByRole("option", { name: /^enp2s0/ }));
await user.click(screen.getByLabelText("Device"));
await user.click(screen.getByRole("option", { name: /^Chosen by MAC/ }));
await user.click(screen.getByLabelText("MAC address"));
expect(screen.getByRole("option", { name: /^AA:BB:CC:DD:EE:FF/ })).toHaveAttribute(
"aria-selected",
"true",
);
await user.click(screen.getByRole("option", { name: /^00:11:22:33:44:55/ }));
await user.click(screen.getByLabelText("Device"));
await user.click(screen.getByRole("option", { name: /^Chosen by name/ }));
await user.click(screen.getByLabelText("Device name"));
expect(screen.getByRole("option", { name: /^enp1s0/ })).toHaveAttribute(
"aria-selected",
"true",
);
});

it("submits with macAddress when binding by MAC", async () => {
const { user } = installerRender(<ConnectionForm />);
await user.type(screen.getByLabelText("Name"), "Testing Connection 1");
Expand Down
13 changes: 10 additions & 3 deletions web/src/components/network/ConnectionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,9 +383,16 @@ function ConnectionFormContent({ defaults, isEditing = false }: ConnectionFormCo
<Flex alignItems={{ default: "alignItemsFlexEnd" }} gap={{ default: "gapMd" }}>
<BindingModeSelector form={form} />

<form.Subscribe selector={(s) => s.values.bindingMode}>
{(mode) => mode !== "none" && <DeviceSelector form={form} by={mode} />}
</form.Subscribe>
{bindingMode === "iface" && (
<DeviceSelector
form={form}
by="iface"
sync={{ field: "ifaceMac", with: (d) => d.macAddress }}
/>
)}
{bindingMode === "mac" && (
<DeviceSelector form={form} by="mac" sync={{ field: "iface", with: (d) => d.name }} />
)}
</Flex>

{!isEditing && (
Expand Down
57 changes: 50 additions & 7 deletions web/src/components/network/DeviceSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,28 @@ jest.mock("~/hooks/model/system/network", () => ({
useDevices: () => mockDevices,
}));

function TestForm({ by }: { by: "iface" | "mac" }) {
type SyncProp = React.ComponentProps<typeof DeviceSelector>["sync"];

let sync: SyncProp;

function TestSelectors() {
const form = useAppForm({ ...connectionFormOptions });
return <DeviceSelector form={form} by={by} />;
return (
<>
<DeviceSelector form={form} by="iface" sync={sync} />
<DeviceSelector form={form} by="mac" />
</>
);
}

describe("DeviceSelector", () => {
beforeEach(() => {
sync = undefined;
});

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

describe("when by is iface", () => {
it("shows device names as options", async () => {
const { user } = installerRender(<TestForm by="iface" />);
const { user } = installerRender(<TestSelectors />);
await user.click(screen.getByLabelText("Device name"));
screen.getByRole("option", { name: /^enp1s0/ });
screen.getByRole("option", { name: /^enp2s0/ });
});

it("shows MAC addresses as option descriptions", async () => {
const { user } = installerRender(<TestForm by="iface" />);
const { user } = installerRender(<TestSelectors />);
await user.click(screen.getByLabelText("Device name"));
screen.getByRole("option", { name: /00:11:22:33:44:55/ });
screen.getByRole("option", { name: /AA:BB:CC:DD:EE:FF/ });
Expand All @@ -82,17 +95,47 @@ describe("DeviceSelector", () => {

describe("when by is mac", () => {
it("shows MAC addresses as options", async () => {
const { user } = installerRender(<TestForm by="mac" />);
const { user } = installerRender(<TestSelectors />);
await user.click(screen.getByLabelText("MAC address"));
screen.getByRole("option", { name: /^00:11:22:33:44:55/ });
screen.getByRole("option", { name: /^AA:BB:CC:DD:EE:FF/ });
});

it("shows device names as option descriptions", async () => {
const { user } = installerRender(<TestForm by="mac" />);
const { user } = installerRender(<TestSelectors />);
await user.click(screen.getByLabelText("MAC address"));
screen.getByRole("option", { name: /enp1s0/ });
screen.getByRole("option", { name: /enp2s0/ });
});
});

describe("when sync is not provided", () => {
it("does not update the synced selector when a device is selected", async () => {
const { user } = installerRender(<TestSelectors />);
await user.click(screen.getByLabelText("Device name"));
await user.click(screen.getByRole("option", { name: /^enp2s0/ }));
await user.click(screen.getByLabelText("MAC address"));
expect(screen.getByRole("option", { name: /^AA:BB:CC:DD:EE:FF/ })).not.toHaveAttribute(
"aria-selected",
"true",
);
});
});

describe("when sync is provided", () => {
beforeEach(() => {
sync = { field: "ifaceMac", with: (d) => d.macAddress };
});

it("updates the synced selector when a device is selected", async () => {
const { user } = installerRender(<TestSelectors />);
await user.click(screen.getByLabelText("Device name"));
await user.click(screen.getByRole("option", { name: /^enp2s0/ }));
await user.click(screen.getByLabelText("MAC address"));
expect(screen.getByRole("option", { name: /^AA:BB:CC:DD:EE:FF/ })).toHaveAttribute(
"aria-selected",
"true",
);
});
});
});
33 changes: 30 additions & 3 deletions web/src/components/network/DeviceSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,33 @@ import Text from "~/components/core/Text";
import { connectionFormOptions } from "~/components/network/ConnectionForm";
import { withForm } from "~/hooks/form";
import { useDevices } from "~/hooks/model/system/network";
import { Device } from "~/types/network";
import { _ } from "~/i18n";

type SyncConfig = {
/** The form field to keep in sync with the selected device. */
field: "iface" | "ifaceMac";
/** Returns the value to write into the synced field for a given device. */
with: (device: Device) => string;
};

/**
* A `ChoiceField` based selector for picking a network device, either by
* interface name or by MAC address.
*
* Receives a typed form instance via `withForm`.
* Receives a typed form instance via `withForm`. When `sync` is provided,
* a TanStack Form listener updates the specified field whenever a device is
* selected, using the `with` function to derive the value to write.
*
* @see https://tanstack.com/form/latest/docs/framework/react/guides/listeners
*/
const DeviceSelector = withForm({
...connectionFormOptions,
props: {
by: "iface" as "iface" | "mac",
},
render: function Render({ form, by }) {
sync: undefined,
} as { by: "iface" | "mac"; sync?: SyncConfig },
render: function Render({ form, by, sync }) {
const devices = useDevices();
const valueKey = by === "iface" ? "name" : "macAddress";

Expand All @@ -64,6 +77,20 @@ const DeviceSelector = withForm({
if (!value && devices.length > 0)
form.setFieldValue(name, devices[0][valueKey], { dontUpdateMeta: true });
},
...(sync && {
onChange: ({ value }: { value: string }) => {
const device =
by === "iface"
? devices.find((d) => d.name === value)
: devices.find((d) => d.macAddress === value);
if (device)
form.setFieldValue(sync.field, sync.with(device), {
// Prevents the counterpart field's listener from firing in
// response, which would otherwise cause an infinite loop.
dontRunListeners: true,
});
},
}),
};

return (
Expand Down
Loading