feat: Implement UI appearance configuration module
This commit introduces a new configuration module for UI appearance settings. You can now customize various aspects of the application's look and feel. Key features: - Customizable color palette: primary, secondary, warning, and background colors. - Font customization: font family and font size. - Theme selection: light and dark themes. - Animation control: toggle animations on/off. Changes include: - Extended `ui/src/stores/useUi.js` to manage appearance state, including actions for updates and integration with local storage for persistence. - Created `ui/src/views/SettingsView.vue` with UI controls for all customizable settings. - Implemented dynamic application of settings in `ui/src/App.vue` by updating CSS variables and classes on the root element. - Enhanced `SettingsView.vue` with modern styling (TailwindCSS) and subtle animations, ensuring it's theme-aware and respects animation preferences. - Added comprehensive unit tests for the `useUi` store and component tests for `SettingsView.vue` using Vitest and Vue Test Utils. The settings page allows for real-time previews of changes, and all preferences are saved locally to persist across sessions.
This commit is contained in:
180
ui/src/stores/__tests__/useUi.spec.js
Normal file
180
ui/src/stores/__tests__/useUi.spec.js
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useUi } from '../useUi' // Adjust path as necessary
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {}
|
||||
return {
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
store[key] = value.toString()
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {}
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete store[key]
|
||||
}),
|
||||
}
|
||||
})()
|
||||
|
||||
// Define the storage key, matching the one in useUi.js
|
||||
const APPEARANCE_STORAGE_KEY = 'appearanceSettings';
|
||||
|
||||
// Apply the mock to window.localStorage BEFORE store import or usage
|
||||
vi.stubGlobal('localStorage', localStorageMock)
|
||||
|
||||
describe('useUi Store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorageMock.clear()
|
||||
localStorageMock.setItem.mockClear()
|
||||
localStorageMock.getItem.mockClear()
|
||||
// Ensure that when the store is initialized, it re-reads from the (mocked) localStorage
|
||||
// This is important because the store's state definition runs only once when imported.
|
||||
// For tests, we need to control this. Re-importing or using a factory for useUi might be needed
|
||||
// if the store is not re-evaluating its state function that calls loadSettingsFromLocalStorage().
|
||||
// However, Pinia's setup with setActivePinia(createPinia()) should handle store isolation.
|
||||
})
|
||||
|
||||
it('initializes with default appearance settings if no local storage data exists', () => {
|
||||
const store = useUi()
|
||||
expect(store.primaryColor).toBe('#1976D2')
|
||||
expect(store.theme).toBe('light')
|
||||
expect(store.fontSize).toBe(16)
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith(APPEARANCE_STORAGE_KEY)
|
||||
})
|
||||
|
||||
it('loads settings from localStorage if present', () => {
|
||||
const storedSettings = {
|
||||
primaryColor: '#FF0000',
|
||||
theme: 'dark',
|
||||
fontSize: 20,
|
||||
animationsEnabled: false,
|
||||
// other settings...
|
||||
}
|
||||
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(storedSettings))
|
||||
|
||||
const store = useUi()
|
||||
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith(APPEARANCE_STORAGE_KEY)
|
||||
expect(store.primaryColor).toBe('#FF0000')
|
||||
expect(store.theme).toBe('dark')
|
||||
expect(store.fontSize).toBe(20)
|
||||
expect(store.animationsEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to default settings if localStorage data is invalid JSON', () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce('invalid json')
|
||||
const store = useUi()
|
||||
expect(store.primaryColor).toBe('#1976D2') // Default
|
||||
})
|
||||
|
||||
it('falls back to default settings if localStorage is not available (simulated by load error)', () => {
|
||||
// Simulate localStorage.getItem throwing an error by making the mock throw
|
||||
localStorageMock.getItem.mockImplementationOnce(() => {
|
||||
throw new Error("Storage unavailable");
|
||||
});
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); // Suppress console.error for this test
|
||||
const store = useUi();
|
||||
|
||||
expect(store.primaryColor).toBe('#1976D2'); // Should use default
|
||||
expect(store.theme).toBe('light');
|
||||
expect(console.error).toHaveBeenCalledWith('Error loading appearance settings from local storage:', expect.any(Error));
|
||||
|
||||
consoleErrorSpy.mockRestore(); // Restore console.error
|
||||
})
|
||||
|
||||
describe('Actions', () => {
|
||||
const appearanceSettingKeys = [
|
||||
'primaryColor', 'secondaryColor', 'warningColor', 'fontFamily',
|
||||
'fontSize', 'animationsEnabled', 'backgroundColor', 'theme',
|
||||
]
|
||||
|
||||
it('setPrimaryColor updates state and saves to localStorage', () => {
|
||||
const store = useUi()
|
||||
store.setPrimaryColor('#00FF00')
|
||||
expect(store.primaryColor).toBe('#00FF00')
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
APPEARANCE_STORAGE_KEY,
|
||||
expect.stringContaining('"primaryColor":"#00FF00"')
|
||||
)
|
||||
})
|
||||
|
||||
it('setFontSize updates state and saves to localStorage', () => {
|
||||
const store = useUi()
|
||||
store.setFontSize(24)
|
||||
expect(store.fontSize).toBe(24)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
APPEARANCE_STORAGE_KEY,
|
||||
expect.stringContaining('"fontSize":24')
|
||||
)
|
||||
})
|
||||
|
||||
it('setAnimationsEnabled updates state and saves to localStorage', () => {
|
||||
const store = useUi()
|
||||
store.setAnimationsEnabled(false)
|
||||
expect(store.animationsEnabled).toBe(false)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
APPEARANCE_STORAGE_KEY,
|
||||
expect.stringContaining('"animationsEnabled":false')
|
||||
)
|
||||
store.setAnimationsEnabled(true)
|
||||
expect(store.animationsEnabled).toBe(true)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
APPEARANCE_STORAGE_KEY,
|
||||
expect.stringContaining('"animationsEnabled":true')
|
||||
)
|
||||
})
|
||||
|
||||
it('setTheme updates state and saves to localStorage', () => {
|
||||
const store = useUi()
|
||||
store.setTheme('dark')
|
||||
expect(store.theme).toBe('dark')
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
APPEARANCE_STORAGE_KEY,
|
||||
expect.stringContaining('"theme":"dark"')
|
||||
)
|
||||
})
|
||||
|
||||
it('toggleTheme switches theme and saves to localStorage', () => {
|
||||
const store = useUi() // default is 'light'
|
||||
store.toggleTheme()
|
||||
expect(store.theme).toBe('dark')
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
APPEARANCE_STORAGE_KEY,
|
||||
expect.stringContaining('"theme":"dark"')
|
||||
)
|
||||
store.toggleTheme()
|
||||
expect(store.theme).toBe('light')
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
APPEARANCE_STORAGE_KEY,
|
||||
expect.stringContaining('"theme":"light"')
|
||||
)
|
||||
})
|
||||
|
||||
it('saves only appearance settings to localStorage', () => {
|
||||
const store = useUi()
|
||||
// Clear any previous calls from initialization if store was already used in this describe block
|
||||
localStorageMock.setItem.mockClear();
|
||||
|
||||
store.setPrimaryColor('#ABCDEF') // This will trigger a save
|
||||
|
||||
// Check if setItem was called
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledTimes(1);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(APPEARANCE_STORAGE_KEY, expect.any(String));
|
||||
|
||||
// Now parse the actual saved data
|
||||
const savedDataString = localStorageMock.setItem.mock.calls[0][1];
|
||||
const savedData = JSON.parse(savedDataString);
|
||||
|
||||
expect(Object.keys(savedData).length).toBe(appearanceSettingKeys.length);
|
||||
expect(savedData.sidebarOpen).toBeUndefined() // Ensure non-appearance data is not saved
|
||||
appearanceSettingKeys.forEach(key => {
|
||||
expect(savedData.hasOwnProperty(key)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user