feat(admin-ui): add persisted layout settings model
This commit is contained in:
142
WonderQ-Admin-UI-Vue/src/lib/layout-settings.test.ts
Normal file
142
WonderQ-Admin-UI-Vue/src/lib/layout-settings.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_LAYOUT_SETTINGS,
|
||||
LAYOUT_SETTINGS_KEY,
|
||||
loadLayoutSettings,
|
||||
normalizeLayoutSettings,
|
||||
resetLayoutSettings,
|
||||
saveLayoutSettings,
|
||||
type LayoutSettings,
|
||||
} from "./layout-settings";
|
||||
|
||||
function createStorage(initial: Record<string, string> = {}): Storage {
|
||||
const values = new Map(Object.entries(initial));
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe("layout settings", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("exposes the RuoYi default settings without sharing the exported object", () => {
|
||||
expect(normalizeLayoutSettings(undefined)).toEqual(DEFAULT_LAYOUT_SETTINGS);
|
||||
expect(normalizeLayoutSettings(undefined)).not.toBe(DEFAULT_LAYOUT_SETTINGS);
|
||||
});
|
||||
|
||||
it("falls back for invalid enum, boolean, and theme values", () => {
|
||||
expect(
|
||||
normalizeLayoutSettings({
|
||||
navType: "invalid",
|
||||
sideTheme: "invalid",
|
||||
theme: "#409ef",
|
||||
tagsView: "yes",
|
||||
tagsViewPersist: 1,
|
||||
tagsIcon: null,
|
||||
tagsViewStyle: "invalid",
|
||||
fixedHeader: "false",
|
||||
sidebarLogo: 0,
|
||||
dynamicTitle: {},
|
||||
footerVisible: [],
|
||||
}),
|
||||
).toEqual(DEFAULT_LAYOUT_SETTINGS);
|
||||
});
|
||||
|
||||
it("filters unknown fields and accepts only six-digit hex themes", () => {
|
||||
const settings = normalizeLayoutSettings({
|
||||
theme: "#12AbEf",
|
||||
unknown: true,
|
||||
});
|
||||
|
||||
expect(settings).toEqual({ ...DEFAULT_LAYOUT_SETTINGS, theme: "#12AbEf" });
|
||||
expect("unknown" in settings).toBe(false);
|
||||
});
|
||||
|
||||
it("saves and loads a settings round-trip", () => {
|
||||
const storage = createStorage();
|
||||
const settings: LayoutSettings = {
|
||||
...DEFAULT_LAYOUT_SETTINGS,
|
||||
navType: "mix",
|
||||
tagsView: false,
|
||||
theme: "#123456",
|
||||
};
|
||||
|
||||
saveLayoutSettings(settings, storage);
|
||||
|
||||
expect(storage.getItem(LAYOUT_SETTINGS_KEY)).toBe(JSON.stringify(settings));
|
||||
expect(loadLayoutSettings(storage)).toEqual(settings);
|
||||
});
|
||||
|
||||
it("safely falls back when storage access or JSON parsing fails", () => {
|
||||
const storage = createStorage({ [LAYOUT_SETTINGS_KEY]: "{" });
|
||||
expect(loadLayoutSettings(storage)).toEqual(DEFAULT_LAYOUT_SETTINGS);
|
||||
|
||||
const throwingStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
} as unknown as Storage;
|
||||
|
||||
expect(loadLayoutSettings(throwingStorage)).toEqual(DEFAULT_LAYOUT_SETTINGS);
|
||||
expect(() => saveLayoutSettings(DEFAULT_LAYOUT_SETTINGS, throwingStorage)).not.toThrow();
|
||||
expect(resetLayoutSettings(throwingStorage)).toEqual(DEFAULT_LAYOUT_SETTINGS);
|
||||
});
|
||||
|
||||
it("normalizes legal JSON with invalid, missing, and unknown stored fields", () => {
|
||||
const storage = createStorage({
|
||||
[LAYOUT_SETTINGS_KEY]: JSON.stringify({
|
||||
navType: "invalid",
|
||||
sideTheme: "theme-light",
|
||||
theme: "blue",
|
||||
tagsView: false,
|
||||
tagsIcon: "yes",
|
||||
tagsViewStyle: "chrome",
|
||||
fixedHeader: true,
|
||||
unknown: "ignored",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(loadLayoutSettings(storage)).toEqual({
|
||||
...DEFAULT_LAYOUT_SETTINGS,
|
||||
sideTheme: "theme-light",
|
||||
tagsView: false,
|
||||
tagsViewStyle: "chrome",
|
||||
fixedHeader: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("safely handles a localStorage getter that throws during default resolution", () => {
|
||||
vi.stubGlobal("window", {
|
||||
get localStorage() {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => loadLayoutSettings()).not.toThrow();
|
||||
expect(() => saveLayoutSettings(DEFAULT_LAYOUT_SETTINGS)).not.toThrow();
|
||||
expect(() => resetLayoutSettings()).not.toThrow();
|
||||
});
|
||||
|
||||
it("resets settings by clearing the persisted value", () => {
|
||||
const storage = createStorage({ [LAYOUT_SETTINGS_KEY]: JSON.stringify({ theme: "#123456" }) });
|
||||
|
||||
expect(resetLayoutSettings(storage)).toEqual(DEFAULT_LAYOUT_SETTINGS);
|
||||
expect(storage.getItem(LAYOUT_SETTINGS_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
130
WonderQ-Admin-UI-Vue/src/lib/layout-settings.ts
Normal file
130
WonderQ-Admin-UI-Vue/src/lib/layout-settings.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
export type NavigationType = "left" | "mix" | "top";
|
||||
export type SideTheme = "theme-dark" | "theme-light";
|
||||
export type TagsViewStyle = "card" | "chrome";
|
||||
|
||||
export interface LayoutSettings {
|
||||
navType: NavigationType;
|
||||
sideTheme: SideTheme;
|
||||
theme: string;
|
||||
tagsView: boolean;
|
||||
tagsViewPersist: boolean;
|
||||
tagsIcon: boolean;
|
||||
tagsViewStyle: TagsViewStyle;
|
||||
fixedHeader: boolean;
|
||||
sidebarLogo: boolean;
|
||||
dynamicTitle: boolean;
|
||||
footerVisible: boolean;
|
||||
}
|
||||
|
||||
export const LAYOUT_SETTINGS_KEY = "wonderq-admin-layout-setting";
|
||||
|
||||
export const DEFAULT_LAYOUT_SETTINGS: LayoutSettings = {
|
||||
navType: "left",
|
||||
sideTheme: "theme-dark",
|
||||
theme: "#409EFF",
|
||||
tagsView: true,
|
||||
tagsViewPersist: false,
|
||||
tagsIcon: false,
|
||||
tagsViewStyle: "card",
|
||||
fixedHeader: false,
|
||||
sidebarLogo: true,
|
||||
dynamicTitle: false,
|
||||
footerVisible: false,
|
||||
};
|
||||
|
||||
const navigationTypes: readonly NavigationType[] = ["left", "mix", "top"];
|
||||
const sideThemes: readonly SideTheme[] = ["theme-dark", "theme-light"];
|
||||
const tagsViewStyles: readonly TagsViewStyle[] = ["card", "chrome"];
|
||||
|
||||
const unavailableStorage: Storage = {
|
||||
get length() {
|
||||
return 0;
|
||||
},
|
||||
clear() {},
|
||||
getItem() {
|
||||
return null;
|
||||
},
|
||||
key() {
|
||||
return null;
|
||||
},
|
||||
removeItem() {},
|
||||
setItem() {},
|
||||
};
|
||||
|
||||
function getDefaultStorage(): Storage {
|
||||
try {
|
||||
if (typeof window === "undefined") {
|
||||
return unavailableStorage;
|
||||
}
|
||||
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return unavailableStorage;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === "object" && input !== null && !Array.isArray(input);
|
||||
}
|
||||
|
||||
function valueOrDefault<T>(value: unknown, values: readonly T[], fallback: T): T {
|
||||
return values.includes(value as T) ? (value as T) : fallback;
|
||||
}
|
||||
|
||||
function booleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function themeOrDefault(value: unknown): string {
|
||||
return typeof value === "string" && /^#[0-9A-Fa-f]{6}$/.test(value)
|
||||
? value
|
||||
: DEFAULT_LAYOUT_SETTINGS.theme;
|
||||
}
|
||||
|
||||
export function normalizeLayoutSettings(input: unknown): LayoutSettings {
|
||||
const source = isRecord(input) ? input : {};
|
||||
|
||||
return {
|
||||
navType: valueOrDefault(source.navType, navigationTypes, DEFAULT_LAYOUT_SETTINGS.navType),
|
||||
sideTheme: valueOrDefault(source.sideTheme, sideThemes, DEFAULT_LAYOUT_SETTINGS.sideTheme),
|
||||
theme: themeOrDefault(source.theme),
|
||||
tagsView: booleanOrDefault(source.tagsView, DEFAULT_LAYOUT_SETTINGS.tagsView),
|
||||
tagsViewPersist: booleanOrDefault(source.tagsViewPersist, DEFAULT_LAYOUT_SETTINGS.tagsViewPersist),
|
||||
tagsIcon: booleanOrDefault(source.tagsIcon, DEFAULT_LAYOUT_SETTINGS.tagsIcon),
|
||||
tagsViewStyle: valueOrDefault(source.tagsViewStyle, tagsViewStyles, DEFAULT_LAYOUT_SETTINGS.tagsViewStyle),
|
||||
fixedHeader: booleanOrDefault(source.fixedHeader, DEFAULT_LAYOUT_SETTINGS.fixedHeader),
|
||||
sidebarLogo: booleanOrDefault(source.sidebarLogo, DEFAULT_LAYOUT_SETTINGS.sidebarLogo),
|
||||
dynamicTitle: booleanOrDefault(source.dynamicTitle, DEFAULT_LAYOUT_SETTINGS.dynamicTitle),
|
||||
footerVisible: booleanOrDefault(source.footerVisible, DEFAULT_LAYOUT_SETTINGS.footerVisible),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLayoutSettings(storage: Storage = getDefaultStorage()): LayoutSettings {
|
||||
try {
|
||||
const serialized = storage.getItem(LAYOUT_SETTINGS_KEY);
|
||||
return serialized === null ? normalizeLayoutSettings(undefined) : normalizeLayoutSettings(JSON.parse(serialized));
|
||||
} catch {
|
||||
return normalizeLayoutSettings(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLayoutSettings(
|
||||
settings: LayoutSettings,
|
||||
storage: Storage = getDefaultStorage(),
|
||||
): void {
|
||||
try {
|
||||
storage.setItem(LAYOUT_SETTINGS_KEY, JSON.stringify(normalizeLayoutSettings(settings)));
|
||||
} catch {
|
||||
// Storage may be unavailable or disabled in the current browser context.
|
||||
}
|
||||
}
|
||||
|
||||
export function resetLayoutSettings(storage: Storage = getDefaultStorage()): LayoutSettings {
|
||||
try {
|
||||
storage.removeItem(LAYOUT_SETTINGS_KEY);
|
||||
} catch {
|
||||
// Storage may be unavailable or disabled in the current browser context.
|
||||
}
|
||||
|
||||
return normalizeLayoutSettings(undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user