feat: shared list kit, settings search, rail order and ui consistency fixes

This commit is contained in:
LukeGus committed 2026-10-06 13:02:05 -05:00
1 parent 3ed5775ed5
commit 58c3434e58
68 files changed
+2078 -512

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import {
AddButton,
FormFooter,
ListRow,
ListRowAction,
ListRowFolder,
PanelList,
} from "@/components/list-kit";
afterEach(cleanup);
describe("ListRow", () => {
it("tints odd rows and marks the selected one", () => {
const { container, rerender } = render(<ListRow title="a" stripe={1} />);
const row = container.firstElementChild as HTMLElement;
expect(row.className).toContain("bg-muted/15");
rerender(<ListRow title="a" stripe={1} selected />);
expect(row.className).toContain("bg-accent-brand/10");
expect(row.getAttribute("data-selected")).toBe("true");
});
it("clicks and opens with Enter, but tray clicks do not reach the row", () => {
const onClick = vi.fn();
const onAction = vi.fn();
render(
<ListRow
title="web"
onClick={onClick}
actions={
<ListRowAction label="Edit" onClick={onAction}>
<span />
</ListRowAction>
}
/>,
);
const row = screen.getByRole("button", { name: /web/ });
fireEvent.keyDown(row, { key: "Enter" });
expect(onClick).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByLabelText("Edit"));
expect(onAction).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("keeps the tray visible while active", () => {
render(
<ListRow
title="a"
active
actions={
<ListRowAction label="More">
<span />
</ListRowAction>
}
/>,
);
const tray = screen.getByLabelText("More").parentElement as HTMLElement;
expect(tray.className).toMatch(/(^| )flex( |$)/);
expect(tray.className).not.toContain("hidden");
});
});
describe("ListRowFolder", () => {
it("toggles and shows the empty text when open with no items", () => {
const onToggle = vi.fn();
const { rerender } = render(
<ListRowFolder
name="Prod"
count={0}
open={false}
onToggle={onToggle}
emptyText="Nothing here"
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Prod/ }));
expect(onToggle).toHaveBeenCalled();
expect(screen.queryByText("Nothing here")).toBeNull();
rerender(
<ListRowFolder
name="Prod"
count={0}
open
onToggle={onToggle}
emptyText="Nothing here"
/>,
);
expect(screen.getByText("Nothing here")).toBeTruthy();
});
});
describe("PanelList", () => {
it("shows the empty slot only without rows", () => {
const { rerender } = render(
<PanelList empty={<span>empty</span>}>{[false, null]}</PanelList>,
);
expect(screen.getByText("empty")).toBeTruthy();
rerender(
<PanelList empty={<span>empty</span>}>
<ListRow title="row" />
</PanelList>,
);
expect(screen.queryByText("empty")).toBeNull();
});
});
describe("AddButton", () => {
it("defaults its label and hides it when compact", () => {
const { rerender } = render(<AddButton />);
expect(screen.getByText("common.new")).toBeTruthy();
rerender(<AddButton compact label="Add host" />);
expect(screen.queryByText("Add host")).toBeNull();
expect(screen.getByLabelText("Add host")).toBeTruthy();
});
});
describe("FormFooter", () => {
it("shows the unsaved note, delete and a saving state", () => {
const onSave = vi.fn();
const onDelete = vi.fn();
const { rerender } = render(
<FormFooter
dirty
onSave={onSave}
onCancel={() => {}}
onDelete={onDelete}
/>,
);
expect(screen.getByText("common.unsavedChanges")).toBeTruthy();
fireEvent.click(screen.getByText("common.delete"));
expect(onDelete).toHaveBeenCalled();
fireEvent.click(screen.getByText("common.save"));
expect(onSave).toHaveBeenCalled();
rerender(<FormFooter saving onSave={onSave} />);
const saving = screen.getByText("common.saving").closest("button");
expect(saving?.disabled).toBe(true);
});
it("lets a status replace the unsaved note", () => {
render(<FormFooter dirty status="2 errors" onSave={() => {}} />);
expect(screen.getByText("2 errors")).toBeTruthy();
expect(screen.queryByText("common.unsavedChanges")).toBeNull();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { EditorPane, EditorSection, LazySection } from "@/manage/EditorPane";
import { Field } from "@/components/form-fields";
import { HIDDEN_ATTR } from "@/settings/page-filter";
afterEach(cleanup);
function renderEditor() {
return render(
<EditorPane
bands={[
{
id: "main",
label: "Main",
items: [
{ id: "general", label: "General" },
{ id: "terminal", label: "Terminal" },
],
},
]}
>
<EditorSection id="general" label="General">
<Field label="Address">
<input />
</Field>
<Field label="Port">
<input />
</Field>
</EditorSection>
<EditorSection id="terminal" label="Terminal">
<LazySection>
<Field label="Font size">
<input />
</Field>
</LazySection>
</EditorSection>
</EditorPane>,
);
}
const hidden = (el: Element | null) => !!el?.closest(`[${HIDDEN_ATTR}]`);
describe("EditorPane search", () => {
it("filters sections and fields, and hides their nav entries", async () => {
renderEditor();
fireEvent.change(screen.getByLabelText("manage.searchSettings"), {
target: { value: "port" },
});
await waitFor(() => {
expect(hidden(screen.getByText("Port"))).toBe(false);
expect(hidden(screen.getByText("Address"))).toBe(true);
expect(hidden(document.querySelector('[data-section="terminal"]'))).toBe(
true,
);
});
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Terminal" })).toBeNull(),
);
});
it("mounts lazy sections so their fields can be found", async () => {
renderEditor();
fireEvent.change(screen.getByLabelText("manage.searchSettings"), {
target: { value: "font" },
});
await waitFor(() => {
expect(hidden(screen.getByText("Font size"))).toBe(false);
expect(hidden(document.querySelector('[data-section="general"]'))).toBe(
true,
);
});
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { filterSettingsPage, HIDDEN_ATTR } from "@/settings/page-filter";
function page() {
const root = document.createElement("div");
root.innerHTML = `
<div class="bg-card" id="theme">
<span>Appearance</span>
<div data-setting-row="" id="font">Font size</div>
<div data-setting-row="" id="accent">Accent color</div>
</div>
<div class="bg-card" id="account"><span>Account</span><div data-setting-row="" id="name">Username</div></div>
<div class="hidden"><div class="bg-card" id="admin">Font policy</div></div>
`;
return root;
}
const hidden = (root: HTMLElement, id: string) =>
root.querySelector(`#${id}`)!.hasAttribute(HIDDEN_ATTR);
describe("filterSettingsPage", () => {
it("keeps only matching cards and rows", () => {
const root = page();
expect(filterSettingsPage(root, "font")).toBe(1);
expect(hidden(root, "theme")).toBe(false);
expect(hidden(root, "font")).toBe(false);
expect(hidden(root, "accent")).toBe(true);
expect(hidden(root, "account")).toBe(true);
});
it("keeps every row when only the card title matches", () => {
const root = page();
filterSettingsPage(root, "appearance");
expect(hidden(root, "font")).toBe(false);
expect(hidden(root, "accent")).toBe(false);
});
it("ignores cards in hidden panels and clears on an empty query", () => {
const root = page();
expect(filterSettingsPage(root, "policy")).toBe(0);
expect(filterSettingsPage(root, "")).toBe(2);
expect(root.querySelectorAll(`[${HIDDEN_ATTR}]`).length).toBe(0);
});
});
@@ -94,9 +94,8 @@ describe("HostAuthOverrideModal", () => {
/>,
);
const select = await screen.findByLabelText(
"hosts.sharing.authOverrideCredentialLabel",
);
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
const select = document.querySelector("select") as HTMLSelectElement;
expect((select as HTMLSelectElement).value).toBe("7");
fireEvent.change(select, { target: { value: "8" } });
@@ -119,9 +118,8 @@ describe("HostAuthOverrideModal", () => {
/>,
);
const select = await screen.findByLabelText(
"hosts.sharing.authOverrideCredentialLabel",
);
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
const select = document.querySelector("select") as HTMLSelectElement;
expect(screen.getByText("hosts.sharing.noPersonalCredential")).toBeTruthy();
fireEvent.change(select, { target: { value: "" } });
expect(screen.getByText("hosts.sharing.authOverrideRequired")).toBeTruthy();
@@ -187,9 +185,8 @@ describe("HostAuthOverrideModal", () => {
/>,
);
const select = await screen.findByLabelText(
"hosts.sharing.authOverrideCredentialLabel",
);
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
const select = document.querySelector("select") as HTMLSelectElement;
expect((select as HTMLSelectElement).value).toBe("19");
expect(remote.get).toHaveBeenCalledWith("/credentials");
expect(api.getCredentials).not.toHaveBeenCalled();
+49
View File
@@ -11,6 +11,8 @@ import {
rightDockableIds,
railItemLabel,
visibleRailItems,
applyRailOrder,
moveRailItem,
} from "@/sidebar/rail-items";
import { isCapturableTabType } from "@/shell/shell-layout";
import { registerPanel, resetPanels } from "@/shell/panel-registry";
@@ -275,3 +277,50 @@ describe("permittedRailItems", () => {
expect(ids).toEqual(["open"]);
});
});
describe("rail order", () => {
const item = (id: string, group: "objects" | "tools" = "tools") =>
({ id, icon: Boxes, labelKey: id, group }) as const;
const items = [
item("hosts", "objects"),
item("credentials", "objects"),
item("snippets"),
item("macros"),
item("history"),
];
it("leaves the default order without a saved one", () => {
expect(applyRailOrder(items, undefined)).toBe(items);
expect(applyRailOrder(items, [])).toBe(items);
});
it("puts listed items in the saved order and keeps the rest in place", () => {
const ids = applyRailOrder(items, ["history", "snippets"]).map(
(entry) => entry.id,
);
expect(ids).toEqual([
"hosts",
"credentials",
"history",
"macros",
"snippets",
]);
});
it("moves an item before another, or to the end of its band", () => {
expect(moveRailItem(items, "history", "snippets")).toEqual([
"hosts",
"credentials",
"history",
"snippets",
"macros",
]);
expect(moveRailItem(items, "hosts", null)).toEqual([
"credentials",
"hosts",
"snippets",
"macros",
"history",
]);
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { showsInBar } from "@/sidebar/tree/host-bar-actions";
describe("showsInBar", () => {
it("follows the action's own default without a choice", () => {
expect(showsInBar({}, { id: "terminal" })).toBe(true);
expect(showsInBar({}, { id: "maintenance", tray: false })).toBe(false);
});
it("lets the user's choice win either way", () => {
expect(
showsInBar({ maintenance: true }, { id: "maintenance", tray: false }),
).toBe(true);
expect(showsInBar({ "ai-agent": false }, { id: "ai-agent" })).toBe(false);
});
});
@@ -28,6 +28,7 @@ describe("defaultHostSidebarPreferences", () => {
openOnDoubleClick: false,
showFolderPaths: true,
hostClickBehavior: "newTab",
barActions: {},
},
});
});
@@ -67,6 +68,7 @@ describe("sanitizeHostSidebarPreferences", () => {
openOnDoubleClick: true,
showFolderPaths: false,
hostClickBehavior: "focusExistingDoubleClickNew",
barActions: { maintenance: true },
},
};
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
@@ -145,3 +147,30 @@ describe("sanitizeHostSidebarPreferences", () => {
expect(result.version).toBe(HOST_SIDEBAR_PREFS_VERSION);
});
});
describe("barActions", () => {
it("keeps boolean choices for valid action ids only", () => {
const prefs = sanitizeHostSidebarPreferences({
display: {
barActions: {
maintenance: true,
"ai-agent": false,
"Bad Id": true,
docker: "yes",
},
},
});
expect(prefs.display.barActions).toEqual({
maintenance: true,
"ai-agent": false,
});
});
it("defaults to no overrides", () => {
expect(sanitizeHostSidebarPreferences({}).display.barActions).toEqual({});
expect(
sanitizeHostSidebarPreferences({ display: { barActions: [] } }).display
.barActions,
).toEqual({});
});
});