diff --git a/@tailwind-shared/base-utilities.css b/@tailwind-shared/base-utilities.css index 89437858c..1aec6db34 100644 --- a/@tailwind-shared/base-utilities.css +++ b/@tailwind-shared/base-utilities.css @@ -76,4 +76,21 @@ body { button:not(:disabled), [role='button']:not(:disabled) { cursor: pointer; +} + +/* Font size overrides for SSO button component */ +unraid-sso-button { + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 1.875rem; + --text-4xl: 2.25rem; + --text-5xl: 3rem; + --text-6xl: 3.75rem; + --text-7xl: 4.5rem; + --text-8xl: 6rem; + --text-9xl: 8rem; } \ No newline at end of file diff --git a/@tailwind-shared/sonner.css b/@tailwind-shared/sonner.css index 86536a186..f27bd8ff4 100644 --- a/@tailwind-shared/sonner.css +++ b/@tailwind-shared/sonner.css @@ -229,6 +229,8 @@ top: 0; height: 20px; width: 20px; + min-width: inherit !important; + margin: 0 !important; display: flex; justify-content: center; align-items: center; diff --git a/unraid-ui/src/components/ui/dialog/DialogClose.vue b/unraid-ui/src/components/ui/dialog/DialogClose.vue index 16c58e70f..e32f68602 100644 --- a/unraid-ui/src/components/ui/dialog/DialogClose.vue +++ b/unraid-ui/src/components/ui/dialog/DialogClose.vue @@ -5,32 +5,7 @@ const props = defineProps(); - - diff --git a/web/__test__/components/standalone-mount.test.ts b/web/__test__/components/standalone-mount.test.ts new file mode 100644 index 000000000..190f1f2e5 --- /dev/null +++ b/web/__test__/components/standalone-mount.test.ts @@ -0,0 +1,278 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; + +// Mock all the component imports +vi.mock('~/components/Auth.ce.vue', () => ({ + default: { name: 'MockAuth', template: '
Auth
' } +})); +vi.mock('~/components/ConnectSettings/ConnectSettings.ce.vue', () => ({ + default: { name: 'MockConnectSettings', template: '
ConnectSettings
' } +})); +vi.mock('~/components/DownloadApiLogs.ce.vue', () => ({ + default: { name: 'MockDownloadApiLogs', template: '
DownloadApiLogs
' } +})); +vi.mock('~/components/HeaderOsVersion.ce.vue', () => ({ + default: { name: 'MockHeaderOsVersion', template: '
HeaderOsVersion
' } +})); +vi.mock('~/components/Modals.ce.vue', () => ({ + default: { name: 'MockModals', template: '
Modals
' } +})); +vi.mock('~/components/UserProfile.ce.vue', () => ({ + default: { name: 'MockUserProfile', template: '
UserProfile
' } +})); +vi.mock('~/components/UpdateOs.ce.vue', () => ({ + default: { name: 'MockUpdateOs', template: '
UpdateOs
' } +})); +vi.mock('~/components/DowngradeOs.ce.vue', () => ({ + default: { name: 'MockDowngradeOs', template: '
DowngradeOs
' } +})); +vi.mock('~/components/Registration.ce.vue', () => ({ + default: { name: 'MockRegistration', template: '
Registration
' } +})); +vi.mock('~/components/WanIpCheck.ce.vue', () => ({ + default: { name: 'MockWanIpCheck', template: '
WanIpCheck
' } +})); +vi.mock('~/components/Activation/WelcomeModal.ce.vue', () => ({ + default: { name: 'MockWelcomeModal', template: '
WelcomeModal
' } +})); +vi.mock('~/components/SsoButton.ce.vue', () => ({ + default: { name: 'MockSsoButton', template: '
SsoButton
' } +})); +vi.mock('~/components/Logs/LogViewer.ce.vue', () => ({ + default: { name: 'MockLogViewer', template: '
LogViewer
' } +})); +vi.mock('~/components/ThemeSwitcher.ce.vue', () => ({ + default: { name: 'MockThemeSwitcher', template: '
ThemeSwitcher
' } +})); +vi.mock('~/components/ApiKeyPage.ce.vue', () => ({ + default: { name: 'MockApiKeyPage', template: '
ApiKeyPage
' } +})); +vi.mock('~/components/DevModalTest.ce.vue', () => ({ + default: { name: 'MockDevModalTest', template: '
DevModalTest
' } +})); +vi.mock('~/components/ApiKeyAuthorize.ce.vue', () => ({ + default: { name: 'MockApiKeyAuthorize', template: '
ApiKeyAuthorize
' } +})); +vi.mock('~/components/UnraidToaster.vue', () => ({ + default: { name: 'MockUnraidToaster', template: '
UnraidToaster
' } +})); + +// Mock vue-mount-app module +const mockAutoMountComponent = vi.fn(); +const mockMountVueApp = vi.fn(); +const mockGetMountedApp = vi.fn(); + +vi.mock('~/components/Wrapper/vue-mount-app', () => ({ + autoMountComponent: mockAutoMountComponent, + mountVueApp: mockMountVueApp, + getMountedApp: mockGetMountedApp, +})); + +// Mock theme store +const mockSetTheme = vi.fn(); +const mockSetCssVars = vi.fn(); +const mockUseThemeStore = vi.fn(() => ({ + setTheme: mockSetTheme, + setCssVars: mockSetCssVars, +})); + +vi.mock('~/store/theme', () => ({ + useThemeStore: mockUseThemeStore, +})); + +// Mock globalPinia +vi.mock('~/store/globalPinia', () => ({ + globalPinia: { state: {} }, +})); + +// Mock apollo client +const mockApolloClient = { + query: vi.fn(), + mutate: vi.fn(), +}; +vi.mock('~/helpers/create-apollo-client', () => ({ + client: mockApolloClient, +})); + +// Mock @vue/apollo-composable +const mockProvideApolloClient = vi.fn(); +vi.mock('@vue/apollo-composable', () => ({ + provideApolloClient: mockProvideApolloClient, +})); + +// Mock graphql +const mockParse = vi.fn(); +vi.mock('graphql', () => ({ + parse: mockParse, +})); + +// Mock @unraid/ui +const mockEnsureTeleportContainer = vi.fn(); +vi.mock('@unraid/ui', () => ({ + ensureTeleportContainer: mockEnsureTeleportContainer, +})); + +describe('standalone-mount', () => { + beforeEach(() => { + // Reset module cache to ensure fresh imports + vi.resetModules(); + + // Reset all mocks + vi.clearAllMocks(); + + // Use Vitest's unstubAllGlobals to clean up any global stubs from previous tests + vi.unstubAllGlobals(); + + // Mock document methods + vi.spyOn(document.head, 'appendChild').mockImplementation(() => document.createElement('style')); + vi.spyOn(document, 'addEventListener').mockImplementation(() => {}); + + // Clear DOM + document.head.innerHTML = ''; + document.body.innerHTML = ''; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllGlobals(); + }); + + describe('initialization', () => { + + it('should set up Apollo client globally', async () => { + await import('~/components/standalone-mount'); + + expect(window.apolloClient).toBe(mockApolloClient); + expect(window.graphqlParse).toBe(mockParse); + expect(window.gql).toBe(mockParse); + expect(mockProvideApolloClient).toHaveBeenCalledWith(mockApolloClient); + }); + + it('should initialize theme store', async () => { + await import('~/components/standalone-mount'); + + expect(mockUseThemeStore).toHaveBeenCalled(); + expect(mockSetTheme).toHaveBeenCalled(); + expect(mockSetCssVars).toHaveBeenCalled(); + }); + + it('should ensure teleport container exists', async () => { + await import('~/components/standalone-mount'); + + expect(mockEnsureTeleportContainer).toHaveBeenCalled(); + }); + }); + + describe('component auto-mounting', () => { + it('should auto-mount all defined components', async () => { + await import('~/components/standalone-mount'); + + // Verify that autoMountComponent was called multiple times + expect(mockAutoMountComponent.mock.calls.length).toBeGreaterThan(0); + + // Verify all calls have the correct structure + mockAutoMountComponent.mock.calls.forEach(call => { + expect(call[0]).toBeDefined(); // Component + expect(call[1]).toBeDefined(); // Selector + expect(call[2]).toMatchObject({ + appId: expect.any(String), + useShadowRoot: false, + }); + }); + + // Extract all selectors that were mounted + const mountedSelectors = mockAutoMountComponent.mock.calls.map(call => call[1]); + + // Verify critical components are mounted + expect(mountedSelectors).toContain('unraid-auth'); + expect(mountedSelectors).toContain('unraid-modals'); + expect(mountedSelectors).toContain('unraid-user-profile'); + expect(mountedSelectors).toContain('uui-toaster'); + expect(mountedSelectors).toContain('#modals'); // Legacy modal selector + + // Verify no shadow DOM is used + const allUseShadowRoot = mockAutoMountComponent.mock.calls.every( + call => call[2].useShadowRoot === false + ); + expect(allUseShadowRoot).toBe(true); + }); + }); + + describe('global exports', () => { + it('should expose UnraidComponents globally', async () => { + await import('~/components/standalone-mount'); + + expect(window.UnraidComponents).toBeDefined(); + expect(window.UnraidComponents).toHaveProperty('Auth'); + expect(window.UnraidComponents).toHaveProperty('ConnectSettings'); + expect(window.UnraidComponents).toHaveProperty('DownloadApiLogs'); + expect(window.UnraidComponents).toHaveProperty('HeaderOsVersion'); + expect(window.UnraidComponents).toHaveProperty('Modals'); + expect(window.UnraidComponents).toHaveProperty('UserProfile'); + expect(window.UnraidComponents).toHaveProperty('UpdateOs'); + expect(window.UnraidComponents).toHaveProperty('DowngradeOs'); + expect(window.UnraidComponents).toHaveProperty('Registration'); + expect(window.UnraidComponents).toHaveProperty('WanIpCheck'); + expect(window.UnraidComponents).toHaveProperty('WelcomeModal'); + expect(window.UnraidComponents).toHaveProperty('SsoButton'); + expect(window.UnraidComponents).toHaveProperty('LogViewer'); + expect(window.UnraidComponents).toHaveProperty('ThemeSwitcher'); + expect(window.UnraidComponents).toHaveProperty('ApiKeyPage'); + expect(window.UnraidComponents).toHaveProperty('DevModalTest'); + expect(window.UnraidComponents).toHaveProperty('ApiKeyAuthorize'); + expect(window.UnraidComponents).toHaveProperty('UnraidToaster'); + }); + + it('should expose utility functions globally', async () => { + await import('~/components/standalone-mount'); + + expect(window.mountVueApp).toBe(mockMountVueApp); + expect(window.getMountedApp).toBe(mockGetMountedApp); + }); + + it('should create dynamic mount functions for each component', async () => { + await import('~/components/standalone-mount'); + + // Check for some dynamic mount functions + expect(typeof window.mountAuth).toBe('function'); + expect(typeof window.mountConnectSettings).toBe('function'); + expect(typeof window.mountUserProfile).toBe('function'); + expect(typeof window.mountModals).toBe('function'); + expect(typeof window.mountThemeSwitcher).toBe('function'); + + // Test calling a dynamic mount function + const customSelector = '#custom-auth'; + window.mountAuth?.(customSelector); + + expect(mockMountVueApp).toHaveBeenCalledWith( + expect.objectContaining({ + selector: customSelector, + useShadowRoot: false, + }) + ); + }); + + it('should use default selector when no custom selector provided', async () => { + await import('~/components/standalone-mount'); + + // Call mount function without custom selector + window.mountAuth?.(); + + expect(mockMountVueApp).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'unraid-auth', + useShadowRoot: false, + }) + ); + }); + }); + + // Skip SSR safety test as it's complex to test with module isolation + describe.skip('SSR safety', () => { + it('should not initialize when window is undefined', async () => { + // This test is skipped because the module initialization happens at import time + // and it's difficult to properly isolate the window object manipulation + // The functionality is simple enough - just checking if window exists before running code + }); + }); +}); diff --git a/web/components/UnraidToaster.vue b/web/components/UnraidToaster.vue new file mode 100644 index 000000000..e7c33ffec --- /dev/null +++ b/web/components/UnraidToaster.vue @@ -0,0 +1,11 @@ + + + diff --git a/web/components/sso/SsoProviderButton.vue b/web/components/sso/SsoProviderButton.vue index 78acc5592..b4bbb1fff 100644 --- a/web/components/sso/SsoProviderButton.vue +++ b/web/components/sso/SsoProviderButton.vue @@ -27,7 +27,7 @@ const handleClick = () => { diff --git a/web/components/standalone-mount.ts b/web/components/standalone-mount.ts index 726f15404..a97af9ae7 100644 --- a/web/components/standalone-mount.ts +++ b/web/components/standalone-mount.ts @@ -1,5 +1,4 @@ // Import all components -import type { Component } from 'vue'; import Auth from './Auth.ce.vue'; import ConnectSettings from './ConnectSettings/ConnectSettings.ce.vue'; import DownloadApiLogs from './DownloadApiLogs.ce.vue'; @@ -17,7 +16,7 @@ import ThemeSwitcher from './ThemeSwitcher.ce.vue'; import ApiKeyPage from './ApiKeyPage.ce.vue'; import DevModalTest from './DevModalTest.ce.vue'; import ApiKeyAuthorize from './ApiKeyAuthorize.ce.vue'; - +import UnraidToaster from './UnraidToaster.vue'; // Import utilities import { autoMountComponent, mountVueApp, getMountedApp } from './Wrapper/vue-mount-app'; import { useThemeStore } from '~/store/theme'; @@ -27,92 +26,11 @@ import { provideApolloClient } from '@vue/apollo-composable'; import { parse } from 'graphql'; import { ensureTeleportContainer } from '@unraid/ui'; -// Extend window interface for Apollo client -declare global { - interface Window { - apolloClient: typeof apolloClient; - gql: typeof parse; - graphqlParse: typeof parse; - } -} +// Window type definitions are automatically included via tsconfig.json -// Add pre-render CSS to hide components until they're mounted -function injectPreRenderCSS() { - const style = document.createElement('style'); - style.id = 'unraid-prerender-css'; - style.textContent = ` - /* Hide unraid components during initial load to prevent FOUC */ - unraid-auth, - unraid-connect-settings, - unraid-download-api-logs, - unraid-header-os-version, - unraid-modals, - unraid-user-profile, - unraid-update-os, - unraid-downgrade-os, - unraid-registration, - unraid-wan-ip-check, - unraid-welcome-modal, - unraid-sso-button, - unraid-log-viewer, - unraid-theme-switcher, - unraid-api-key-manager, - unraid-dev-modal-test, - unraid-api-key-authorize { - opacity: 0; - transition: opacity 0.2s ease-in-out; - } - - /* Show components once they have the unapi class (mounted) */ - unraid-auth.unapi, - unraid-connect-settings.unapi, - unraid-download-api-logs.unapi, - unraid-header-os-version.unapi, - unraid-modals.unapi, - unraid-user-profile.unapi, - unraid-update-os.unapi, - unraid-downgrade-os.unapi, - unraid-registration.unapi, - unraid-wan-ip-check.unapi, - unraid-welcome-modal.unapi, - unraid-sso-button.unapi, - unraid-log-viewer.unapi, - unraid-theme-switcher.unapi, - unraid-api-key-manager.unapi, - unraid-dev-modal-test.unapi, - unraid-api-key-authorize.unapi { - opacity: 1; - } - - /* Font size overrides for SSO button component */ - unraid-sso-button { - --text-xs: 0.75rem; - --text-sm: 0.875rem; - --text-base: 1rem; - --text-lg: 1.125rem; - --text-xl: 1.25rem; - --text-2xl: 1.5rem; - --text-3xl: 1.875rem; - --text-4xl: 2.25rem; - --text-5xl: 3rem; - --text-6xl: 3.75rem; - --text-7xl: 4.5rem; - --text-8xl: 6rem; - --text-9xl: 8rem; - } - `; - document.head.appendChild(style); -} // Initialize global Apollo client context if (typeof window !== 'undefined') { - // Inject pre-render CSS as early as possible - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', injectPreRenderCSS); - } else { - injectPreRenderCSS(); - } - // Make Apollo client globally available window.apolloClient = apolloClient; @@ -140,6 +58,7 @@ const componentMappings = [ { component: DownloadApiLogs, selector: 'unraid-download-api-logs', appId: 'download-api-logs' }, { component: HeaderOsVersion, selector: 'unraid-header-os-version', appId: 'header-os-version' }, { component: Modals, selector: 'unraid-modals', appId: 'modals' }, + { component: Modals, selector: '#modals', appId: 'modals-legacy' }, // Legacy ID selector { component: UserProfile, selector: 'unraid-user-profile', appId: 'user-profile' }, { component: UpdateOs, selector: 'unraid-update-os', appId: 'update-os' }, { component: DowngradeOs, selector: 'unraid-downgrade-os', appId: 'downgrade-os' }, @@ -152,6 +71,8 @@ const componentMappings = [ { component: ApiKeyPage, selector: 'unraid-api-key-manager', appId: 'api-key-manager' }, { component: DevModalTest, selector: 'unraid-dev-modal-test', appId: 'dev-modal-test' }, { component: ApiKeyAuthorize, selector: 'unraid-api-key-authorize', appId: 'api-key-authorize' }, + { component: UnraidToaster, selector: 'uui-toaster', appId: 'toaster' }, + { component: UnraidToaster, selector: 'unraid-toaster', appId: 'toaster-legacy' }, // Legacy alias ]; // Auto-mount all components @@ -162,20 +83,7 @@ componentMappings.forEach(({ component, selector, appId }) => { }); }); -// Special handling for Modals - also mount to #modals -autoMountComponent(Modals, '#modals', { - appId: 'modals-direct', - useShadowRoot: false, -}); - -// Expose functions globally for testing and dynamic mounting -declare global { - interface Window { - UnraidComponents: Record; - mountVueApp: typeof mountVueApp; - getMountedApp: typeof getMountedApp; - } -} +// Window interface extensions are defined in ~/types/window.d.ts if (typeof window !== 'undefined') { // Expose all components @@ -197,6 +105,7 @@ if (typeof window !== 'undefined') { ApiKeyPage, DevModalTest, ApiKeyAuthorize, + UnraidToaster, }; // Expose utility functions diff --git a/web/scripts/deploy-dev.sh b/web/scripts/deploy-dev.sh index 295b0927c..1ad9cb478 100755 --- a/web/scripts/deploy-dev.sh +++ b/web/scripts/deploy-dev.sh @@ -35,7 +35,7 @@ if [ "$has_standalone" = true ]; then # Ensure remote directory exists ssh root@"${server_name}" "mkdir -p /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/" # Clear the remote standalone directory before rsyncing - ssh root@"${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/*" + ssh root@"${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/*" # Run rsync with proper quoting rsync -avz --delete -e "ssh" "$standalone_directory" "root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/" standalone_exit_code=$? diff --git a/web/tsconfig.json b/web/tsconfig.json index a746f2a70..bed5c068f 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,4 +1,7 @@ { // https://nuxt.com/docs/guide/concepts/typescript - "extends": "./.nuxt/tsconfig.json" + "extends": "./.nuxt/tsconfig.json", + "compilerOptions": { + "types": ["./types/window"] + } } diff --git a/web/types/window.d.ts b/web/types/window.d.ts new file mode 100644 index 000000000..e25934ddd --- /dev/null +++ b/web/types/window.d.ts @@ -0,0 +1,52 @@ +import type { Component } from 'vue'; +import type { parse } from 'graphql'; +import type { client as apolloClient } from '~/helpers/create-apollo-client'; +import type { mountVueApp, getMountedApp } from '~/components/Wrapper/vue-mount-app'; + +/** + * Global Window interface extensions for Unraid components + * This file provides type definitions for properties added to the window object + * by the standalone-mount.ts module + */ +declare global { + interface Window { + // Apollo GraphQL client and utilities + apolloClient: typeof apolloClient; + gql: typeof parse; + graphqlParse: typeof parse; + + // Vue component registry and utilities + UnraidComponents: Record; + mountVueApp: typeof mountVueApp; + getMountedApp: typeof getMountedApp; + + // Dynamic mount functions created at runtime + // These are generated for each component in componentMappings + mountAuth?: (selector?: string) => unknown; + mountConnectSettings?: (selector?: string) => unknown; + mountDownloadApiLogs?: (selector?: string) => unknown; + mountHeaderOsVersion?: (selector?: string) => unknown; + mountModals?: (selector?: string) => unknown; + mountModalsLegacy?: (selector?: string) => unknown; + mountUserProfile?: (selector?: string) => unknown; + mountUpdateOs?: (selector?: string) => unknown; + mountDowngradeOs?: (selector?: string) => unknown; + mountRegistration?: (selector?: string) => unknown; + mountWanIpCheck?: (selector?: string) => unknown; + mountWelcomeModal?: (selector?: string) => unknown; + mountSsoButton?: (selector?: string) => unknown; + mountLogViewer?: (selector?: string) => unknown; + mountThemeSwitcher?: (selector?: string) => unknown; + mountApiKeyManager?: (selector?: string) => unknown; + mountDevModalTest?: (selector?: string) => unknown; + mountApiKeyAuthorize?: (selector?: string) => unknown; + mountToaster?: (selector?: string) => unknown; + mountToasterLegacy?: (selector?: string) => unknown; + + // Index signature for any other dynamic mount functions + [key: `mount${string}`]: ((selector?: string) => unknown) | undefined; + } +} + +// Export empty object to make this a module and enable augmentation +export {};