fix: UnraidToaster component and update dialog close button (#1657)

- Introduced a new UnraidToaster component for displaying notifications
with customizable positions.
- Updated the DialogClose component to use a span element for better
semantic structure.
- Enhanced CSS for the sonner component to ensure proper layout and
styling.

These changes improve user feedback through notifications and refine the
dialog close button's implementation.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a toaster notifications component with configurable screen
position, rich colors, and a close button; programmatic and legacy
mounting helpers exposed.

* **Style**
  * Updated toast close-button spacing and min-width behavior.
* Simplified dialog close-button rendering and removed redundant style
resets.
* Reduced SSO provider icon size and added SSO button font-size tokens.

* **Tests**
  * Added unit tests covering component mounting and global exports.

* **Chores**
  * Deployment now performs broader remote cleanup before syncing.
* **Chores**
* Type declarations and tsconfig updated for global mount/utility
typings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-09-04 12:34:16 -04:00
committed by GitHub
parent e204eb80a0
commit 44774d0acd
10 changed files with 374 additions and 127 deletions

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -5,32 +5,7 @@ const props = defineProps<DialogCloseProps>();
</script>
<template>
<DialogClose v-bind="props">
<DialogClose v-bind="props" as="span">
<slot />
</DialogClose>
</template>
<style>
/* Reset webgui button styles for dialog close buttons */
[role='dialog'] button[type='button'],
button[aria-label*='close' i],
button[aria-label*='dismiss' i] {
/* Reset ALL webgui button styles using !important where needed */
all: unset !important;
/* Re-apply necessary styles after reset */
display: inline-flex !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
cursor: pointer !important;
box-sizing: border-box !important;
/* Reset any webgui CSS variables */
--button-border: none !important;
--button-text-color: inherit !important;
--button-background: transparent !important;
--button-background-size: auto !important;
}
</style>

View File

@@ -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: '<div>Auth</div>' }
}));
vi.mock('~/components/ConnectSettings/ConnectSettings.ce.vue', () => ({
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' }
}));
vi.mock('~/components/DownloadApiLogs.ce.vue', () => ({
default: { name: 'MockDownloadApiLogs', template: '<div>DownloadApiLogs</div>' }
}));
vi.mock('~/components/HeaderOsVersion.ce.vue', () => ({
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' }
}));
vi.mock('~/components/Modals.ce.vue', () => ({
default: { name: 'MockModals', template: '<div>Modals</div>' }
}));
vi.mock('~/components/UserProfile.ce.vue', () => ({
default: { name: 'MockUserProfile', template: '<div>UserProfile</div>' }
}));
vi.mock('~/components/UpdateOs.ce.vue', () => ({
default: { name: 'MockUpdateOs', template: '<div>UpdateOs</div>' }
}));
vi.mock('~/components/DowngradeOs.ce.vue', () => ({
default: { name: 'MockDowngradeOs', template: '<div>DowngradeOs</div>' }
}));
vi.mock('~/components/Registration.ce.vue', () => ({
default: { name: 'MockRegistration', template: '<div>Registration</div>' }
}));
vi.mock('~/components/WanIpCheck.ce.vue', () => ({
default: { name: 'MockWanIpCheck', template: '<div>WanIpCheck</div>' }
}));
vi.mock('~/components/Activation/WelcomeModal.ce.vue', () => ({
default: { name: 'MockWelcomeModal', template: '<div>WelcomeModal</div>' }
}));
vi.mock('~/components/SsoButton.ce.vue', () => ({
default: { name: 'MockSsoButton', template: '<div>SsoButton</div>' }
}));
vi.mock('~/components/Logs/LogViewer.ce.vue', () => ({
default: { name: 'MockLogViewer', template: '<div>LogViewer</div>' }
}));
vi.mock('~/components/ThemeSwitcher.ce.vue', () => ({
default: { name: 'MockThemeSwitcher', template: '<div>ThemeSwitcher</div>' }
}));
vi.mock('~/components/ApiKeyPage.ce.vue', () => ({
default: { name: 'MockApiKeyPage', template: '<div>ApiKeyPage</div>' }
}));
vi.mock('~/components/DevModalTest.ce.vue', () => ({
default: { name: 'MockDevModalTest', template: '<div>DevModalTest</div>' }
}));
vi.mock('~/components/ApiKeyAuthorize.ce.vue', () => ({
default: { name: 'MockApiKeyAuthorize', template: '<div>ApiKeyAuthorize</div>' }
}));
vi.mock('~/components/UnraidToaster.vue', () => ({
default: { name: 'MockUnraidToaster', template: '<div>UnraidToaster</div>' }
}));
// 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
});
});
});

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
import { Toaster } from '@unraid/ui';
defineProps<{
position: 'top-center' | 'top-right' | 'top-left' | 'bottom-center' | 'bottom-right' | 'bottom-left';
}>();
</script>
<template>
<Toaster rich-colors close-button :position="position" />
</template>

View File

@@ -27,7 +27,7 @@ const handleClick = () => {
<img
v-if="props.provider.buttonIcon"
:src="props.provider.buttonIcon"
class="w-6 h-6 sso-button-icon flex-shrink-0"
class="w-4 h-4 sso-button-icon flex-shrink-0"
alt=""
aria-hidden="true"
>

View File

@@ -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<string, Component>;
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

View File

@@ -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=$?

View File

@@ -1,4 +1,7 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"types": ["./types/window"]
}
}

52
web/types/window.d.ts vendored Normal file
View File

@@ -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<string, Component>;
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 {};