UI: keep unfinished settings routes hidden
This commit is contained in:
parent
1e142a3a2c
commit
fece56306f
@ -1,19 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applySettings,
|
||||
attachThemeListener,
|
||||
setTabFromRoute,
|
||||
syncThemeWithSettings,
|
||||
} from "./app-settings.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
import type { ThemeMode, ThemeName } from "./theme.ts";
|
||||
|
||||
type SettingsHost = Parameters<typeof setTabFromRoute>[0] & {
|
||||
type Tab =
|
||||
| "agents"
|
||||
| "overview"
|
||||
| "channels"
|
||||
| "instances"
|
||||
| "sessions"
|
||||
| "usage"
|
||||
| "cron"
|
||||
| "skills"
|
||||
| "nodes"
|
||||
| "chat"
|
||||
| "config"
|
||||
| "communications"
|
||||
| "appearance"
|
||||
| "automation"
|
||||
| "infrastructure"
|
||||
| "aiAgents"
|
||||
| "debug"
|
||||
| "logs";
|
||||
|
||||
type AppSettingsModule = typeof import("./app-settings.ts");
|
||||
|
||||
type SettingsHost = {
|
||||
settings: {
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
sessionKey: string;
|
||||
lastActiveSessionKey: string;
|
||||
theme: ThemeName;
|
||||
themeMode: ThemeMode;
|
||||
chatFocusMode: boolean;
|
||||
chatShowThinking: boolean;
|
||||
splitRatio: number;
|
||||
navCollapsed: boolean;
|
||||
navWidth: number;
|
||||
navGroupsCollapsed: Record<string, boolean>;
|
||||
};
|
||||
theme: ThemeName & ThemeMode;
|
||||
themeMode: ThemeMode;
|
||||
themeResolved: import("./theme.ts").ResolvedTheme;
|
||||
applySessionKey: string;
|
||||
sessionKey: string;
|
||||
tab: Tab;
|
||||
connected: boolean;
|
||||
chatHasAutoScrolled: boolean;
|
||||
logsAtBottom: boolean;
|
||||
eventLog: unknown[];
|
||||
eventLogBuffer: unknown[];
|
||||
basePath: string;
|
||||
themeMedia: MediaQueryList | null;
|
||||
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
|
||||
logsPollInterval: number | null;
|
||||
debugPollInterval: number | null;
|
||||
};
|
||||
|
||||
function createStorageMock(): Storage {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(store.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const createHost = (tab: Tab): SettingsHost => ({
|
||||
settings: {
|
||||
gatewayUrl: "",
|
||||
@ -48,37 +114,50 @@ const createHost = (tab: Tab): SettingsHost => ({
|
||||
});
|
||||
|
||||
describe("setTabFromRoute", () => {
|
||||
let appSettings: AppSettingsModule;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
|
||||
vi.stubGlobal("window", {
|
||||
setInterval,
|
||||
clearInterval,
|
||||
} as Window & typeof globalThis);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("starts and stops log polling based on the tab", () => {
|
||||
it("starts and stops log polling based on the tab", async () => {
|
||||
appSettings ??= await import("./app-settings.ts");
|
||||
const host = createHost("chat");
|
||||
|
||||
setTabFromRoute(host, "logs");
|
||||
appSettings.setTabFromRoute(host, "logs");
|
||||
expect(host.logsPollInterval).not.toBeNull();
|
||||
expect(host.debugPollInterval).toBeNull();
|
||||
|
||||
setTabFromRoute(host, "chat");
|
||||
appSettings.setTabFromRoute(host, "chat");
|
||||
expect(host.logsPollInterval).toBeNull();
|
||||
});
|
||||
|
||||
it("starts and stops debug polling based on the tab", () => {
|
||||
it("starts and stops debug polling based on the tab", async () => {
|
||||
appSettings ??= await import("./app-settings.ts");
|
||||
const host = createHost("chat");
|
||||
|
||||
setTabFromRoute(host, "debug");
|
||||
appSettings.setTabFromRoute(host, "debug");
|
||||
expect(host.debugPollInterval).not.toBeNull();
|
||||
expect(host.logsPollInterval).toBeNull();
|
||||
|
||||
setTabFromRoute(host, "chat");
|
||||
appSettings.setTabFromRoute(host, "chat");
|
||||
expect(host.debugPollInterval).toBeNull();
|
||||
});
|
||||
|
||||
it("re-resolves the active palette when only themeMode changes", () => {
|
||||
it("re-resolves the active palette when only themeMode changes", async () => {
|
||||
appSettings ??= await import("./app-settings.ts");
|
||||
const host = createHost("chat");
|
||||
host.settings.theme = "knot";
|
||||
host.settings.themeMode = "dark";
|
||||
@ -86,7 +165,7 @@ describe("setTabFromRoute", () => {
|
||||
host.themeMode = "dark";
|
||||
host.themeResolved = "openknot";
|
||||
|
||||
applySettings(host, {
|
||||
appSettings.applySettings(host, {
|
||||
...host.settings,
|
||||
themeMode: "light",
|
||||
});
|
||||
@ -96,19 +175,21 @@ describe("setTabFromRoute", () => {
|
||||
expect(host.themeResolved).toBe("openknot-light");
|
||||
});
|
||||
|
||||
it("syncs both theme family and mode from persisted settings", () => {
|
||||
it("syncs both theme family and mode from persisted settings", async () => {
|
||||
appSettings ??= await import("./app-settings.ts");
|
||||
const host = createHost("chat");
|
||||
host.settings.theme = "dash";
|
||||
host.settings.themeMode = "light";
|
||||
|
||||
syncThemeWithSettings(host);
|
||||
appSettings.syncThemeWithSettings(host);
|
||||
|
||||
expect(host.theme).toBe("dash");
|
||||
expect(host.themeMode).toBe("light");
|
||||
expect(host.themeResolved).toBe("dash-light");
|
||||
});
|
||||
|
||||
it("applies named system themes on OS preference changes", () => {
|
||||
it("applies named system themes on OS preference changes", async () => {
|
||||
appSettings ??= await import("./app-settings.ts");
|
||||
const listeners: Array<(event: MediaQueryListEvent) => void> = [];
|
||||
const matchMedia = vi.fn().mockReturnValue({
|
||||
matches: false,
|
||||
@ -118,12 +199,17 @@ describe("setTabFromRoute", () => {
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("matchMedia", matchMedia);
|
||||
vi.stubGlobal("window", {
|
||||
setInterval,
|
||||
clearInterval,
|
||||
matchMedia,
|
||||
} as Window & typeof globalThis);
|
||||
|
||||
const host = createHost("chat");
|
||||
host.theme = "knot" as unknown as ThemeName & ThemeMode;
|
||||
host.themeMode = "system";
|
||||
|
||||
attachThemeListener(host);
|
||||
appSettings.attachThemeListener(host);
|
||||
listeners[0]?.({ matches: true } as MediaQueryListEvent);
|
||||
expect(host.themeResolved).toBe("openknot");
|
||||
|
||||
|
||||
@ -1,9 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TAB_GROUPS } from "./navigation.ts";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type NavigationModule = typeof import("./navigation.ts");
|
||||
|
||||
function createStorageMock(): Storage {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(store.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("TAB_GROUPS", () => {
|
||||
let navigation: NavigationModule;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
|
||||
navigation = await import("./navigation.ts");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not expose unfinished settings slices in the sidebar", () => {
|
||||
const settings = TAB_GROUPS.find((group) => group.label === "settings");
|
||||
const settings = navigation.TAB_GROUPS.find((group) => group.label === "settings");
|
||||
expect(settings?.tabs).toEqual(["config", "debug", "logs"]);
|
||||
});
|
||||
|
||||
it("does not route directly into unfinished settings slices", () => {
|
||||
expect(navigation.tabFromPath("/communications")).toBeNull();
|
||||
expect(navigation.tabFromPath("/appearance")).toBeNull();
|
||||
expect(navigation.tabFromPath("/automation")).toBeNull();
|
||||
expect(navigation.tabFromPath("/infrastructure")).toBeNull();
|
||||
expect(navigation.tabFromPath("/ai-agents")).toBeNull();
|
||||
expect(navigation.tabFromPath("/config")).toBe("config");
|
||||
});
|
||||
});
|
||||
|
||||
@ -55,7 +55,19 @@ const TAB_PATHS: Record<Tab, string> = {
|
||||
logs: "/logs",
|
||||
};
|
||||
|
||||
const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));
|
||||
const HIDDEN_SETTINGS_TABS = new Set<Tab>([
|
||||
"communications",
|
||||
"appearance",
|
||||
"automation",
|
||||
"infrastructure",
|
||||
"aiAgents",
|
||||
]);
|
||||
|
||||
const PATH_TO_TAB = new Map(
|
||||
Object.entries(TAB_PATHS)
|
||||
.filter(([tab]) => !HIDDEN_SETTINGS_TABS.has(tab as Tab))
|
||||
.map(([tab, path]) => [path, tab as Tab]),
|
||||
);
|
||||
|
||||
export function normalizeBasePath(basePath: string): string {
|
||||
if (!basePath) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user