-
Notifications
You must be signed in to change notification settings - Fork 22
feat(web): consolidated single-component header for Unraid 7.3+ (fixes mobile overlap) #2037
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e7f99e7
e9f8439
1ae725d
925471d
ffa88cd
deb43f5
171e605
b07813d
6a086d7
1cadc92
fff9e0d
0e0ce16
a1f74df
eb17187
81d4e4f
91d56cb
fb0550e
ac1ec3c
27aca7b
67345f9
ae0a69b
0ed01c8
26bff15
30c97ca
003854b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,236 @@ | ||
| import { ref } from 'vue'; | ||
| import { setActivePinia } from 'pinia'; | ||
| import { provideApolloClient } from '@vue/apollo-composable'; | ||
| import { mount } from '@vue/test-utils'; | ||
|
|
||
| import { ApolloClient, InMemoryCache } from '@apollo/client/core'; | ||
| import { createTestingPinia } from '@pinia/testing'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import type { VueWrapper } from '@vue/test-utils'; | ||
| import type { Server, ServerconnectPluginInstalled, ServerState } from '~/types/server'; | ||
| import type { Pinia } from 'pinia'; | ||
|
|
||
| import Header from '~/components/Header.standalone.vue'; | ||
| import { useServerStore } from '~/store/server'; | ||
| import { useThemeStore } from '~/store/theme'; | ||
|
|
||
| const mockCopy = vi.fn(); | ||
| const mockCopied = ref(false); | ||
| const mockIsSupported = ref(true); | ||
|
|
||
| vi.mock('@vueuse/core', () => ({ | ||
| useClipboard: () => { | ||
| const actualCopy = (text: string) => { | ||
| if (mockIsSupported.value) { | ||
| mockCopy(text); | ||
| mockCopied.value = true; | ||
| } else { | ||
| mockCopied.value = false; | ||
| } | ||
| }; | ||
| return { | ||
| copy: actualCopy, | ||
| copied: mockCopied, | ||
| isSupported: mockIsSupported, | ||
| }; | ||
| }, | ||
| useLocalStorage: <T>(key: string, initialValue: T) => { | ||
| const storage = new Map<string, T>(); | ||
| if (!storage.has(key)) { | ||
| storage.set(key, initialValue); | ||
| } | ||
| return ref(storage.get(key) ?? initialValue); | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock('@unraid/ui', () => ({ | ||
| DropdownMenu: { | ||
| template: '<div data-testid="dropdown-menu"><slot name="trigger" /><slot name="content" /></div>', | ||
| }, | ||
| Button: { | ||
| template: '<button><slot /></button>', | ||
| props: ['variant', 'size'], | ||
| }, | ||
| cn: (...classes: string[]) => classes.filter(Boolean).join(' '), | ||
| isDarkModeActive: vi.fn(() => false), | ||
| })); | ||
|
|
||
| const mockWatcher = vi.fn(); | ||
|
|
||
| vi.mock('~/store/callbackActions', () => ({ | ||
| useCallbackActionsStore: vi.fn(() => ({ | ||
| watcher: mockWatcher, | ||
| callbackData: ref(null), | ||
| })), | ||
| })); | ||
|
|
||
| const t = (key: string, args?: unknown[]) => (args ? `${key} ${JSON.stringify(args)}` : key); | ||
| vi.mock('vue-i18n', () => ({ | ||
| useI18n: () => ({ t }), | ||
| })); | ||
|
|
||
| const initialServerData: Server = { | ||
| name: 'DEVGEN', | ||
| description: 'Dev Server', | ||
| guid: 'TEST-GUID', | ||
| keyfile: 'keyfile.key', | ||
| lanIp: '192.168.1.100', | ||
| connectPluginInstalled: 'dynamix.unraid.net.plg' as ServerconnectPluginInstalled, | ||
| state: 'PRO' as ServerState, | ||
| dateTimeFormat: { date: 'YYYY-MM-DD', time: 'HH:mm' }, | ||
| deviceCount: 5, | ||
| flashProduct: 'TestFlash', | ||
| flashVendor: 'TestVendor', | ||
| regGuid: 'REG-GUID', | ||
| regTm: 1678886400, | ||
| regTo: 'Test User', | ||
| regTy: 'Pro', | ||
| regExp: undefined, | ||
| regUpdatesExpired: false, | ||
| registered: true, | ||
| wanIp: '8.8.8.8', | ||
| }; | ||
|
|
||
| // Stub the heavier child components; the consolidated header is responsible for | ||
| // layout composition, which is what these tests exercise. | ||
| const stubs = { | ||
| HeaderVersion: { template: '<div data-testid="header-version"></div>' }, | ||
| ArrayUsage: { template: '<div data-testid="array-usage"></div>' }, | ||
| UpcServerStatus: { | ||
| template: '<div data-testid="server-status"></div>', | ||
| props: ['class'], | ||
| }, | ||
| NotificationsSidebar: { template: '<div data-testid="notifications-sidebar"></div>' }, | ||
| DropdownMenu: { | ||
| template: '<div data-testid="dropdown-menu"><slot name="trigger" /><slot name="content" /></div>', | ||
| }, | ||
| UpcDropdownContent: { template: '<div data-testid="dropdown-content"></div>' }, | ||
| UpcDropdownTrigger: { template: '<button data-testid="dropdown-trigger"></button>' }, | ||
| }; | ||
|
|
||
| describe('Header.standalone.vue', () => { | ||
| let wrapper: VueWrapper<InstanceType<typeof Header>>; | ||
| let pinia: Pinia; | ||
| let serverStore: ReturnType<typeof useServerStore>; | ||
| let themeStore: ReturnType<typeof useThemeStore>; | ||
| let consoleSpies: Array<ReturnType<typeof vi.spyOn>> = []; | ||
|
|
||
| beforeEach(() => { | ||
| Object.defineProperty(window, 'location', { | ||
| value: { | ||
| hostname: 'localhost', | ||
| port: '3000', | ||
| pathname: '/', | ||
| protocol: 'http:', | ||
| href: 'http://localhost:3000/', | ||
| }, | ||
| writable: true, | ||
| configurable: true, | ||
| }); | ||
|
|
||
| provideApolloClient(new ApolloClient({ cache: new InMemoryCache() })); | ||
|
|
||
| consoleSpies = [ | ||
| vi.spyOn(console, 'log').mockImplementation(() => {}), | ||
| vi.spyOn(console, 'warn').mockImplementation(() => {}), | ||
| vi.spyOn(console, 'debug').mockImplementation(() => {}), | ||
| vi.spyOn(console, 'error').mockImplementation(() => {}), | ||
| ]; | ||
|
|
||
| mockCopied.value = false; | ||
| mockIsSupported.value = true; | ||
|
|
||
| pinia = createTestingPinia({ | ||
| createSpy: vi.fn, | ||
| initialState: { server: { ...initialServerData } }, | ||
| stubActions: false, | ||
| }); | ||
| setActivePinia(pinia); | ||
|
|
||
| serverStore = useServerStore(); | ||
| themeStore = useThemeStore(); | ||
| themeStore.setTheme({ | ||
| name: 'white', | ||
| banner: true, | ||
| bannerGradient: true, | ||
| descriptionShow: true, | ||
| textColor: '', | ||
| metaColor: '', | ||
| bgColor: '', | ||
| }); | ||
|
|
||
| vi.spyOn(serverStore, 'setServer').mockImplementation((server) => { | ||
| Object.assign(serverStore, server); | ||
| return server; | ||
| }); | ||
|
|
||
| vi.clearAllMocks(); | ||
|
|
||
| wrapper = mount(Header, { | ||
| props: { server: JSON.stringify(initialServerData) }, | ||
| global: { plugins: [pinia], stubs }, | ||
| }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| wrapper?.unmount(); | ||
| consoleSpies.forEach((spy) => spy.mockRestore()); | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it('renders every header region in a single component', () => { | ||
| expect(wrapper.find('[data-testid="header-version"]').exists()).toBe(true); | ||
| expect(wrapper.find('[data-testid="server-status"]').exists()).toBe(true); | ||
| expect(wrapper.find('[data-testid="notifications-sidebar"]').exists()).toBe(true); | ||
| expect(wrapper.find('[data-testid="dropdown-menu"]').exists()).toBe(true); | ||
| expect(wrapper.find('[data-testid="dropdown-trigger"]').exists()).toBe(true); | ||
| expect(wrapper.find('button').text()).toContain('DEVGEN'); | ||
| }); | ||
|
|
||
| it('hydrates the server store from the prop and starts the callback watcher', () => { | ||
| expect(serverStore.setServer).toHaveBeenCalledTimes(1); | ||
| expect(serverStore.setServer).toHaveBeenCalledWith(JSON.parse(JSON.stringify(initialServerData))); | ||
| expect(mockWatcher).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('throws when the server prop is missing', () => { | ||
| expect(() => mount(Header, { props: {}, global: { plugins: [pinia], stubs } })).toThrow( | ||
| 'Server data not present' | ||
| ); | ||
| }); | ||
|
|
||
| it('lays out on a responsive grid with each region in its own area (no overlap)', () => { | ||
| const root = wrapper.get('#UnraidHeader'); | ||
| // A single grid owns the responsive layout (breakpoints live in scoped CSS). | ||
| expect(root.classes()).toContain('unraid-header-grid'); | ||
|
Check failure on line 206 in web/__test__/components/Header.test.ts
|
||
| // Every header region is placed in its own grid area, so nothing is absolutely | ||
| // positioned on top of anything else (the root cause of the old mobile overlap). | ||
| expect(root.find('.uh-logo').exists()).toBe(true); | ||
| expect(root.find('.uh-meta').exists()).toBe(true); | ||
| expect(root.find('.uh-status').exists()).toBe(true); | ||
| expect(root.find('.uh-name').exists()).toBe(true); | ||
| expect(root.find('.uh-actions').exists()).toBe(true); | ||
| // The only absolutely-positioned element allowed is the banner gradient layer. | ||
| expect(root.classes()).not.toContain('absolute'); | ||
| expect(root.find('.absolute:not(.unraid-banner-gradient-layer)').exists()).toBe(false); | ||
| }); | ||
|
|
||
| it('hides the array-usage bar by default and shows it when enabled', async () => { | ||
| expect(wrapper.find('[data-testid="array-usage"]').exists()).toBe(false); | ||
|
|
||
| const withUsage = mount(Header, { | ||
| props: { server: JSON.stringify(initialServerData), showArrayUsage: 'true' }, | ||
| global: { plugins: [pinia], stubs }, | ||
| }); | ||
| expect(withUsage.find('[data-testid="array-usage"]').exists()).toBe(true); | ||
| withUsage.unmount(); | ||
| }); | ||
|
|
||
| it('copies the LAN IP when the server name is clicked', async () => { | ||
| mockIsSupported.value = true; | ||
| await wrapper.find('button').trigger('click'); | ||
| await wrapper.vm.$nextTick(); | ||
| expect(mockCopy).toHaveBeenCalledWith(initialServerData.lanIp); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Avoid asserting the exact error message text.
Per coding guidelines, tests should assert that an error is thrown, not the exact wording, to avoid breaking on incidental message changes.
✏️ Proposed fix
it('throws when the server prop is missing', () => { - expect(() => mount(Header, { props: {}, global: { plugins: [pinia], stubs } })).toThrow( - 'Server data not present' - ); + expect(() => mount(Header, { props: {}, global: { plugins: [pinia], stubs } })).toThrow(); });📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines