mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: shared list kit, settings search, rail order and ui consistency fixes
This commit is contained in:
1 parent
3ed5775ed5
commit
58c3434e58
68 files changed
+2078
-512
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user