mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: finish the homepage plugin, move service links and canvas out of core (B19)
This commit is contained in:
1 parent
21937a6b02
commit
d4ec444558
256 files changed
+27365
-9077
No files matched your search
@@ -1,183 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { ClockWidget } from "../../../features/homepage/widgets/ClockWidget";
|
||||
import { ClockEditForm } from "../../../features/homepage/dialogs/ClockEditForm";
|
||||
import {
|
||||
normalizeTimezone,
|
||||
validateClockTimezone,
|
||||
} from "../../../features/homepage/clock-timezone";
|
||||
import type { CanvasWidget, ClockConfig } from "@/types/homepage-types";
|
||||
|
||||
const widget: CanvasWidget = {
|
||||
id: 1,
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: {},
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 8,
|
||||
h: 5,
|
||||
zOrder: 0,
|
||||
};
|
||||
|
||||
function renderClock(config: Partial<ClockConfig> = {}) {
|
||||
return render(
|
||||
<ClockWidget
|
||||
widget={widget}
|
||||
config={{ showSeconds: false, format: "24h", ...config }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderForm(config: Partial<ClockConfig> = {}) {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ClockEditForm
|
||||
config={{ showSeconds: false, format: "24h", ...config }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
onChange,
|
||||
input: screen.getByPlaceholderText(/America\/New_York/) as HTMLInputElement,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("ClockWidget timezone handling", () => {
|
||||
it("renders instead of throwing when a saved config holds an invalid timezone", () => {
|
||||
// Regression: "America/New York" (space, not underscore) made
|
||||
// toLocaleTimeString throw a RangeError and took down the whole canvas.
|
||||
expect(() => renderClock({ timezone: "America/New York" })).not.toThrow();
|
||||
expect(screen.queryByText("America/New York")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to local time formatting for an invalid timezone", () => {
|
||||
renderClock({ timezone: "Not/AZone" });
|
||||
const local = new Date().toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
expect(screen.getByText(local)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps and displays a valid timezone", () => {
|
||||
renderClock({ timezone: "America/New_York" });
|
||||
expect(screen.getByText("America/New_York")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no timezone label when none is configured", () => {
|
||||
const { container } = renderClock();
|
||||
expect(
|
||||
container.querySelector('[class*="text-muted-foreground/60"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("formats in the configured timezone, not the host timezone", () => {
|
||||
renderClock({ timezone: "Asia/Tokyo" });
|
||||
const tokyo = new Date().toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Asia/Tokyo",
|
||||
});
|
||||
expect(screen.getByText(tokyo)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateClockTimezone", () => {
|
||||
it("turns spaces into underscores", () => {
|
||||
expect(validateClockTimezone("America/New York")).toEqual({
|
||||
timezone: "America/New_York",
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(validateClockTimezone(" Europe/Paris ")).toEqual({
|
||||
timezone: "Europe/Paris",
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unusable zone as invalid but still returns it normalized", () => {
|
||||
expect(validateClockTimezone("Not/AZone")).toEqual({
|
||||
timezone: "Not/AZone",
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats blank input as clearing the timezone", () => {
|
||||
expect(validateClockTimezone(" ")).toEqual({
|
||||
timezone: undefined,
|
||||
valid: true,
|
||||
});
|
||||
expect(validateClockTimezone(undefined)).toEqual({
|
||||
timezone: undefined,
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes every run of whitespace", () => {
|
||||
expect(normalizeTimezone("America/Argentina/Buenos Aires")).toBe(
|
||||
"America/Argentina/Buenos_Aires",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClockEditForm", () => {
|
||||
it("stores what the user typed without rewriting it mid-edit", () => {
|
||||
const { onChange, input } = renderForm();
|
||||
fireEvent.change(input, { target: { value: "America/Los Angeles" } });
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timezone: "America/Los Angeles" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags an unusable zone the same way FolderMetadataDialog flags a duplicate name", () => {
|
||||
const { input } = renderForm({ timezone: "Not/AZone" });
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(input.className).toContain("border-destructive");
|
||||
expect(screen.getByText("homepage.invalidTimezone")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts a zone typed with a space, since it is normalized before checking", () => {
|
||||
const { input } = renderForm({ timezone: "America/New York" });
|
||||
expect(input.getAttribute("aria-invalid")).toBe("false");
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
});
|
||||
|
||||
it("says nothing about an empty field", () => {
|
||||
const { input } = renderForm();
|
||||
expect(input.getAttribute("aria-invalid")).toBe("false");
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the timezone when the field is emptied", () => {
|
||||
const { onChange, input } = renderForm({ timezone: "Europe/Paris" });
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timezone: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the other clock settings when the timezone changes", () => {
|
||||
const { onChange, input } = renderForm({
|
||||
showSeconds: true,
|
||||
format: "12h",
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "UTC" } });
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
timezone: "UTC",
|
||||
showSeconds: true,
|
||||
format: "12h",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const getVersionInfo = vi.fn();
|
||||
const getDatabaseHealth = vi.fn(async () => ({ status: "ok" }));
|
||||
vi.mock("@/api/system-status-api", () => ({
|
||||
getVersionInfo: (...args: unknown[]) => getVersionInfo(...args),
|
||||
getDatabaseHealth: () => getDatabaseHealth(),
|
||||
}));
|
||||
vi.mock("@/api/dashboard-api", () => ({
|
||||
getUptime: vi.fn(async () => ({ formatted: "1d 2h" })),
|
||||
}));
|
||||
|
||||
// The real one installs an interval and a visibilitychange listener; the
|
||||
// component's first fetch happens outside it, which is all these tests need.
|
||||
vi.mock("../../../features/homepage/use-visible-interval", () => ({
|
||||
runVisibleInterval: () => () => {},
|
||||
}));
|
||||
|
||||
import { SystemOverviewWidget } from "../../../features/homepage/widgets/SystemOverviewWidget";
|
||||
import type { CanvasWidget } from "@/types/homepage-types";
|
||||
|
||||
const widget: CanvasWidget = {
|
||||
id: 1,
|
||||
typeId: "system_overview",
|
||||
title: "Termix",
|
||||
config: {},
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 10,
|
||||
h: 6,
|
||||
zOrder: 0,
|
||||
};
|
||||
|
||||
function renderWidget() {
|
||||
render(
|
||||
<SystemOverviewWidget
|
||||
widget={widget}
|
||||
config={{ showVersion: true, showDbHealth: false, showUptime: false }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getVersionInfo.mockReset();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("SystemOverviewWidget update indicator", () => {
|
||||
it("asks /version to actually check the remote release", async () => {
|
||||
// Regression: it used to pass checkRemote=false, which makes the endpoint
|
||||
// return early without ever comparing against the latest release.
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "up_to_date",
|
||||
localVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() => expect(getVersionInfo).toHaveBeenCalledTimes(1));
|
||||
expect(getVersionInfo).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("flags an available update from the status the endpoint returns", async () => {
|
||||
// Regression: it used to read `updateAvailable`, a field /version does not
|
||||
// return in either mode, so the row could never appear.
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "requires_update",
|
||||
localVersion: "2.6.0",
|
||||
remoteVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("homepage.overviewUpdateAvailable")).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
|
||||
it("labels that row 'Update' rather than 'Up to date'", async () => {
|
||||
// homepage.overviewUpdate is the string "Up to date", which read as the
|
||||
// label of an update-available row said "Up to date Update available".
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "requires_update",
|
||||
localVersion: "2.6.0",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("homepage.overviewUpdateLabel")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByText("homepage.overviewUpdate")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no update row when the server is current", async () => {
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "up_to_date",
|
||||
localVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() => expect(screen.getByText("2.6.1")).toBeTruthy());
|
||||
expect(screen.queryByText("homepage.overviewUpdateAvailable")).toBeNull();
|
||||
expect(screen.queryByText("homepage.overviewUpdateLabel")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no update row on a beta build, which is ahead rather than behind", async () => {
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "beta",
|
||||
localVersion: "2.7.0",
|
||||
remoteVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() => expect(screen.getByText("2.7.0")).toBeTruthy());
|
||||
expect(screen.queryByText("homepage.overviewUpdateAvailable")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { WidgetEditDialog } from "../../../features/homepage/dialogs/WidgetEditDialog";
|
||||
import type { CanvasWidget } from "@/types/homepage-types";
|
||||
|
||||
function clockWidget(timezone?: string): CanvasWidget {
|
||||
return {
|
||||
id: 7,
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: { timezone, showSeconds: true, format: "24h" },
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 8,
|
||||
h: 5,
|
||||
zOrder: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function open(timezone?: string) {
|
||||
const onSave = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<WidgetEditDialog
|
||||
widget={clockWidget(timezone)}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
onSave,
|
||||
onClose,
|
||||
input: screen.getByPlaceholderText(/America\/New_York/) as HTMLInputElement,
|
||||
save: screen
|
||||
.getByText("homepage.save")
|
||||
.closest("button") as HTMLButtonElement,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("WidgetEditDialog clock timezone validation", () => {
|
||||
it("stores the normalized zone when the user typed a space", () => {
|
||||
const { onSave, onClose, input, save } = open();
|
||||
fireEvent.change(input, { target: { value: "America/New York" } });
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
expect(save.disabled).toBe(false);
|
||||
|
||||
fireEvent.click(save);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Clock",
|
||||
expect.objectContaining({ timezone: "America/New_York" }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Save while the zone is unusable", () => {
|
||||
const { onSave, onClose, input, save } = open();
|
||||
fireEvent.change(input, { target: { value: "Not/AZone" } });
|
||||
|
||||
expect(save.disabled).toBe(true);
|
||||
expect(screen.getByText("homepage.invalidTimezone")).toBeTruthy();
|
||||
|
||||
fireEvent.click(save);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-enables Save once the zone becomes usable", () => {
|
||||
const { onSave, input, save } = open();
|
||||
fireEvent.change(input, { target: { value: "Not/AZone" } });
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(input, { target: { value: "Asia/Tokyo" } });
|
||||
expect(save.disabled).toBe(false);
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
|
||||
fireEvent.click(save);
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Clock",
|
||||
expect.objectContaining({ timezone: "Asia/Tokyo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves a blank field as no timezone", () => {
|
||||
const { onSave, input, save } = open("Europe/Paris");
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
fireEvent.click(save);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Clock",
|
||||
expect.objectContaining({ timezone: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
screenToCanvas,
|
||||
canvasToScreen,
|
||||
zoomAroundPoint,
|
||||
} from "../../../../features/homepage/canvas/canvasGeometry";
|
||||
|
||||
const PAN = { x: 100, y: 200 };
|
||||
const ZOOM = 2;
|
||||
|
||||
describe("screenToCanvas", () => {
|
||||
it("inverts canvasToScreen", () => {
|
||||
const canvas = { x: 50, y: 75 };
|
||||
const screen = canvasToScreen(canvas.x, canvas.y, PAN, ZOOM);
|
||||
const back = screenToCanvas(screen.x, screen.y, PAN, ZOOM);
|
||||
expect(back.x).toBeCloseTo(canvas.x);
|
||||
expect(back.y).toBeCloseTo(canvas.y);
|
||||
});
|
||||
|
||||
it("accounts for pan offset", () => {
|
||||
const result = screenToCanvas(100, 200, PAN, 1);
|
||||
expect(result.x).toBe(0);
|
||||
expect(result.y).toBe(0);
|
||||
});
|
||||
|
||||
it("accounts for zoom", () => {
|
||||
const result = screenToCanvas(300, 400, PAN, ZOOM);
|
||||
expect(result.x).toBe((300 - 100) / 2);
|
||||
expect(result.y).toBe((400 - 200) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvasToScreen", () => {
|
||||
it("maps origin with pan offset", () => {
|
||||
const result = canvasToScreen(0, 0, PAN, ZOOM);
|
||||
expect(result.x).toBe(100);
|
||||
expect(result.y).toBe(200);
|
||||
});
|
||||
|
||||
it("scales by zoom", () => {
|
||||
const result = canvasToScreen(10, 20, PAN, ZOOM);
|
||||
expect(result.x).toBe(120);
|
||||
expect(result.y).toBe(240);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zoomAroundPoint", () => {
|
||||
it("keeps the mouse point on the same canvas coordinate", () => {
|
||||
const mouseX = 400;
|
||||
const mouseY = 300;
|
||||
const pan = { x: 0, y: 0 };
|
||||
const oldZoom = 1;
|
||||
const newZoom = 2;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
|
||||
|
||||
// Canvas coordinate under mouse before zoom
|
||||
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
|
||||
// Canvas coordinate under mouse after zoom with newPan
|
||||
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
|
||||
|
||||
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
|
||||
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
|
||||
});
|
||||
|
||||
it("returns correct pan for zoom-out", () => {
|
||||
const mouseX = 200;
|
||||
const mouseY = 150;
|
||||
const pan = { x: 50, y: 50 };
|
||||
const oldZoom = 2;
|
||||
const newZoom = 1;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
|
||||
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
|
||||
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
|
||||
|
||||
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
|
||||
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
snapToGrid,
|
||||
snapToGridFloor,
|
||||
} from "../../../../features/homepage/canvas/snapToGrid";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
describe("snapToGrid", () => {
|
||||
it("snaps to nearest grid multiple", () => {
|
||||
expect(snapToGrid(0)).toBe(0);
|
||||
expect(snapToGrid(GRID_SIZE)).toBe(GRID_SIZE);
|
||||
expect(snapToGrid(GRID_SIZE * 2)).toBe(GRID_SIZE * 2);
|
||||
});
|
||||
|
||||
it("rounds to nearest (up when exactly halfway)", () => {
|
||||
expect(snapToGrid(GRID_SIZE / 2)).toBe(GRID_SIZE);
|
||||
expect(snapToGrid(GRID_SIZE * 1.5)).toBe(GRID_SIZE * 2);
|
||||
});
|
||||
|
||||
it("rounds down when below halfway", () => {
|
||||
expect(snapToGrid(GRID_SIZE * 0.4)).toBe(0);
|
||||
expect(snapToGrid(GRID_SIZE * 1.4)).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it("works with negative values", () => {
|
||||
expect(snapToGrid(-GRID_SIZE)).toBe(-GRID_SIZE);
|
||||
expect(snapToGrid(-GRID_SIZE * 0.4)).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapToGridFloor", () => {
|
||||
it("always rounds down to floor multiple", () => {
|
||||
expect(snapToGridFloor(GRID_SIZE - 1)).toBe(0);
|
||||
expect(snapToGridFloor(GRID_SIZE)).toBe(GRID_SIZE);
|
||||
expect(snapToGridFloor(GRID_SIZE + 1)).toBe(GRID_SIZE);
|
||||
expect(snapToGridFloor(GRID_SIZE * 2 - 1)).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it("handles zero", () => {
|
||||
expect(snapToGridFloor(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const registered = vi.hoisted(() => ({
|
||||
cards: new Map<
|
||||
string,
|
||||
{ defaultHeight?: number; defaultPanel?: "main" | "side" }
|
||||
>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/dashboard/dashboard-cards-registry", () => ({
|
||||
getRegisteredDashboardCard: (id: string) => registered.cards.get(id),
|
||||
}));
|
||||
|
||||
const { buildDashboardSlots } = await import("../../lib/apply-ui-preset.js");
|
||||
|
||||
afterEach(() => {
|
||||
registered.cards.clear();
|
||||
});
|
||||
|
||||
describe("buildDashboardSlots", () => {
|
||||
it("places core cards in the main panel by default", () => {
|
||||
const slots = buildDashboardSlots(["stats_bar", "quick_actions"]);
|
||||
expect(slots.map((s) => s.panel)).toEqual(["main", "main"]);
|
||||
expect(slots.map((s) => s.height)).toEqual([96, 160]);
|
||||
});
|
||||
|
||||
it("keeps recent_activity in the side panel", () => {
|
||||
const slots = buildDashboardSlots(["stats_bar", "recent_activity"]);
|
||||
expect(slots.find((s) => s.id === "recent_activity")?.panel).toBe("side");
|
||||
});
|
||||
|
||||
it("reads a plugin card's own height and panel from the registry", () => {
|
||||
registered.cards.set("service_links", {
|
||||
defaultHeight: 200,
|
||||
defaultPanel: "side",
|
||||
});
|
||||
const slots = buildDashboardSlots(["stats_bar", "service_links"]);
|
||||
const link = slots.find((s) => s.id === "service_links");
|
||||
expect(link?.panel).toBe("side");
|
||||
expect(link?.height).toBe(200);
|
||||
});
|
||||
|
||||
it("defaults an unregistered plugin card to the main panel with no fixed height", () => {
|
||||
const slots = buildDashboardSlots(["some_plugin_card"]);
|
||||
expect(slots).toEqual([
|
||||
{
|
||||
key: "some_plugin_card_0",
|
||||
id: "some_plugin_card",
|
||||
panel: "main",
|
||||
order: 0,
|
||||
height: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders main and side cards independently", () => {
|
||||
registered.cards.set("service_links", { defaultPanel: "side" });
|
||||
const slots = buildDashboardSlots([
|
||||
"stats_bar",
|
||||
"service_links",
|
||||
"quick_actions",
|
||||
"recent_activity",
|
||||
]);
|
||||
expect(slots.map((s) => [s.id, s.panel, s.order])).toEqual([
|
||||
["stats_bar", "main", 0],
|
||||
["service_links", "side", 0],
|
||||
["quick_actions", "main", 1],
|
||||
["recent_activity", "side", 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import { useState } from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import {
|
||||
homepageWidgetType,
|
||||
useActivityTypes,
|
||||
useHomepageWidgetTypes,
|
||||
useHostActions,
|
||||
usePermission,
|
||||
useTranslation,
|
||||
type TermixApp,
|
||||
@@ -35,6 +39,7 @@ import { resetRegisteredRailItems } from "@/sidebar/rail-items";
|
||||
import { resetTabTypes } from "@/shell/tab-registry";
|
||||
import { resetPanels } from "@/shell/panel-registry";
|
||||
import { resetDashboardCards } from "@/dashboard/dashboard-cards-registry";
|
||||
import { resetHomepageWidgetTypes } from "@/plugin-host/homepage-widget-registry";
|
||||
import { resetSettingsComponents } from "@/settings/settings-components";
|
||||
import { resetActionRegistry } from "@/shell/action-registry";
|
||||
import { resetPluginStore } from "@/plugin-host/plugin-store";
|
||||
@@ -82,9 +87,11 @@ function activate(app: TermixApp) {
|
||||
titleKey: "title",
|
||||
});
|
||||
app.registerPanel("fixture-panel", () => <Counter />);
|
||||
app.registerTab("fixture-tab", ({ host }) => (
|
||||
<span>tab for {host?.name ?? "nobody"}</span>
|
||||
));
|
||||
app.registerTab(
|
||||
"fixture-tab",
|
||||
({ host }) => <span>tab for {host?.name ?? "nobody"}</span>,
|
||||
{ activityTypes: ["fixture_activity"] },
|
||||
);
|
||||
app.registerHostEditorSection({
|
||||
id: "fixture-section",
|
||||
group: "ssh",
|
||||
@@ -125,6 +132,17 @@ function activate(app: TermixApp) {
|
||||
),
|
||||
});
|
||||
app.registerSettingsComponent("probe", PermissionProbe);
|
||||
app.registerHomepageWidget({
|
||||
id: "fixture-widget",
|
||||
name: "Fixture Widget",
|
||||
description: "A fixture widget",
|
||||
category: "info",
|
||||
icon: <Puzzle size={14} />,
|
||||
defaultConfig: {},
|
||||
defaultSize: { w: 4, h: 4 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
component: () => <span>fixture widget</span>,
|
||||
});
|
||||
}
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
@@ -150,6 +168,7 @@ afterEach(async () => {
|
||||
resetHostEditorSections();
|
||||
resetPaletteEntries();
|
||||
resetPluginStore();
|
||||
resetHomepageWidgetTypes();
|
||||
});
|
||||
|
||||
const host = (overrides: Partial<Host> = {}) =>
|
||||
@@ -259,6 +278,37 @@ describe("registries through the app object", () => {
|
||||
expect(resolveHostTabType(on)).toBe("fixture-tab");
|
||||
});
|
||||
|
||||
it("exposes registered host actions through the SDK's useHostActions", async () => {
|
||||
await mount();
|
||||
function Probe() {
|
||||
const actions = useHostActions();
|
||||
return <span>{actions.map((a) => a.id).join(",")}</span>;
|
||||
}
|
||||
render(<Probe />);
|
||||
expect(await screen.findByText("fixture-connect")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exposes a registered tab's activityTypes through useActivityTypes", async () => {
|
||||
await mount();
|
||||
function Probe() {
|
||||
const types = useActivityTypes();
|
||||
return <span>{types.join(",")}</span>;
|
||||
}
|
||||
render(<Probe />);
|
||||
expect(await screen.findByText(/fixture_activity/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("registers a homepage widget type readable through the SDK", async () => {
|
||||
await mount();
|
||||
expect(homepageWidgetType("fixture-widget")?.name).toBe("Fixture Widget");
|
||||
function Probe() {
|
||||
const types = useHomepageWidgetTypes();
|
||||
return <span>{types.map((w) => w.id).join(",")}</span>;
|
||||
}
|
||||
render(<Probe />);
|
||||
expect(await screen.findByText("fixture-widget")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers a per-host palette entry that runs against the shell", async () => {
|
||||
const app = await mount();
|
||||
const target = host({ enableFixture: true } as Partial<Host>);
|
||||
|
||||
Reference in new issue
Block a user