mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2026-08-04 15:28:58 +00:00
Pull request 2688: AGDNS-4164 add update and tls banners
Some checks failed
build / test (macOS-latest) (push) Has been cancelled
build / test (ubuntu-latest) (push) Has been cancelled
build / test (windows-latest) (push) Has been cancelled
lint / go-lint (push) Has been cancelled
lint / eslint (push) Has been cancelled
build / build-release (push) Has been cancelled
build / notify (push) Has been cancelled
lint / notify (push) Has been cancelled
Some checks failed
build / test (macOS-latest) (push) Has been cancelled
build / test (ubuntu-latest) (push) Has been cancelled
build / test (windows-latest) (push) Has been cancelled
lint / go-lint (push) Has been cancelled
lint / eslint (push) Has been cancelled
build / build-release (push) Has been cancelled
build / notify (push) Has been cancelled
lint / notify (push) Has been cancelled
Squashed commit of the following:
commit 6a98822c92c37a08bb59966acdaec60012e7f1e0
Author: Ildar Kamalov <ik@adguard.com>
Date: Wed Jul 1 10:17:50 2026 +0300
fix footer order
commit 2da1d3da5928c0ea4b276e8618126db01ed9c3e9
Author: Ildar Kamalov <ik@adguard.com>
Date: Tue Jun 30 17:49:45 2026 +0300
fix build
commit 44dad0ee96f41688bd183270704494698516b5f8
Author: Ildar Kamalov <ik@adguard.com>
Date: Tue Jun 30 16:06:47 2026 +0300
fix footer columns
commit d2fff9e8bb562aea208ff80001a64ebfbf37fde2
Author: Ildar Kamalov <ik@adguard.com>
Date: Tue Jun 30 15:34:26 2026 +0300
fix footer version
commit 59420eff26d7290e970ebe664d88cd021d9c7712
Author: Ildar Kamalov <ik@adguard.com>
Date: Tue Jun 30 15:27:29 2026 +0300
fix build
commit b94c8462a2e64df140db5fdc542d088200b95bb6
Author: Ildar Kamalov <ik@adguard.com>
Date: Tue Jun 30 14:58:34 2026 +0300
AGDNS-4164 add update and tls banners
This commit is contained in:
parent
42e7dcc31c
commit
5a55c03ec6
29 changed files with 1098 additions and 137 deletions
|
|
@ -33,7 +33,7 @@
|
|||
- 'Artifact'
|
||||
|
||||
- 'E2E':
|
||||
manual: false
|
||||
manual: true
|
||||
final: false
|
||||
jobs:
|
||||
- 'Test e2e'
|
||||
|
|
|
|||
|
|
@ -840,5 +840,12 @@
|
|||
"clients": "Clients",
|
||||
"aria_clear_input": "Clear input",
|
||||
"aria_previous_page": "Previous page",
|
||||
"aria_next_page": "Next page"
|
||||
"aria_next_page": "Next page",
|
||||
"update_available": "Version %version% is available. <a>Release notes</a>",
|
||||
"update_button": "Update",
|
||||
"update_how_to": "How to update",
|
||||
"tls_certificate_expiring": "Your TLS certificate is about to expire",
|
||||
"tls_certificate_expired": "Your TLS certificate has expired",
|
||||
"version_number": "Version %value%",
|
||||
"update_failed": "Auto-update failed. Please <a>follow these steps</a> to update manually"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ vi.mock('panel/stores/dashboard', () => ({
|
|||
vi.mock('panel/common/ui/Header', () => ({
|
||||
Header: () => <div data-testid="chrome-header" />,
|
||||
}));
|
||||
vi.mock('panel/common/ui/Banners', () => ({
|
||||
Banners: () => <div data-testid="chrome-banners" />,
|
||||
}));
|
||||
vi.mock('panel/common/ui/Sidebar', () => ({
|
||||
Sidebar: () => <div data-testid="chrome-sidebar" />,
|
||||
}));
|
||||
|
|
@ -59,4 +62,27 @@ describe('App routing', () => {
|
|||
|
||||
expect(await screen.findByTestId('route-dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mounts Banners between Header and the wrapper in the main entry', async () => {
|
||||
window.location.hash = '#/dashboard';
|
||||
|
||||
render(() => <App />);
|
||||
|
||||
const banners = await screen.findByTestId('chrome-banners');
|
||||
expect(banners).toBeInTheDocument();
|
||||
|
||||
// Verify ordering: Header → Banners → Sidebar (wrapper starts)
|
||||
const header = screen.getByTestId('chrome-header');
|
||||
const sidebar = screen.getByTestId('chrome-sidebar');
|
||||
|
||||
// Banners should be after Header in DOM
|
||||
expect(
|
||||
header.compareDocumentPosition(banners) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
|
||||
// Banners should be before Sidebar in DOM
|
||||
expect(
|
||||
banners.compareDocumentPosition(sidebar) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
74
client_v3/src/__tests__/common/ui/Banner.test.tsx
Normal file
74
client_v3/src/__tests__/common/ui/Banner.test.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { render, screen } from '@solidjs/testing-library';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { Banner } from 'panel/common/ui/Banner';
|
||||
|
||||
describe('Banner', () => {
|
||||
it('renders the message slot', () => {
|
||||
render(() => <Banner variant="info" message="Test message" />);
|
||||
expect(screen.getByText('Test message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the action slot when provided', () => {
|
||||
render(() => (
|
||||
<Banner
|
||||
variant="info"
|
||||
message="Test"
|
||||
action={<button type="button">Click me</button>}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies the correct variant class', () => {
|
||||
const { container } = render(() => <Banner variant="warning" message="Warning!" />);
|
||||
const banner = container.firstElementChild as HTMLElement;
|
||||
expect(banner.className).toContain('warning');
|
||||
});
|
||||
|
||||
it('sets role="alert" and aria-live="assertive" for critical variant', () => {
|
||||
const { container } = render(() => <Banner variant="critical" message="Critical!" />);
|
||||
const banner = container.firstElementChild as HTMLElement;
|
||||
expect(banner.getAttribute('role')).toBe('alert');
|
||||
expect(banner.getAttribute('aria-live')).toBe('assertive');
|
||||
});
|
||||
|
||||
it('sets role="status" and aria-live="polite" for non-critical variants', () => {
|
||||
const { container } = render(() => <Banner variant="info" message="Info" />);
|
||||
const banner = container.firstElementChild as HTMLElement;
|
||||
expect(banner.getAttribute('role')).toBe('status');
|
||||
expect(banner.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('forwards the data-testid prop', () => {
|
||||
render(() => <Banner variant="info" message="Test" data-testid="custom-banner" />);
|
||||
expect(screen.getByTestId('custom-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the close button when onClose is provided', () => {
|
||||
const onClose = vi.fn();
|
||||
render(() => (
|
||||
<Banner variant="info" message="Test" onClose={onClose} data-testid="test-banner" />
|
||||
));
|
||||
const closeButton = screen.getByTestId('test-banner-close');
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
expect(closeButton.getAttribute('aria-label')).toBe('Close notification');
|
||||
});
|
||||
|
||||
it('does not render the close button when onClose is not provided', () => {
|
||||
render(() => <Banner variant="info" message="Test" data-testid="test-banner" />);
|
||||
expect(screen.queryByTestId('test-banner-close')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when the close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(() => (
|
||||
<Banner variant="info" message="Test" onClose={onClose} data-testid="test-banner" />
|
||||
));
|
||||
const closeButton = screen.getByTestId('test-banner-close');
|
||||
await user.click(closeButton);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
264
client_v3/src/__tests__/common/ui/Banners.test.tsx
Normal file
264
client_v3/src/__tests__/common/ui/Banners.test.tsx
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@solidjs/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HashRouter, Route } from '@solidjs/router';
|
||||
|
||||
// We'll mock the stores at the module level and update them per test
|
||||
const mockDashboardState = {
|
||||
isUpdateAvailable: false,
|
||||
newVersion: '',
|
||||
announcementUrl: '',
|
||||
canAutoUpdate: false,
|
||||
processingUpdate: false,
|
||||
processingVersion: false,
|
||||
dnsVersion: '',
|
||||
};
|
||||
|
||||
const mockEncryptionState = {
|
||||
enabled: false,
|
||||
not_after: '',
|
||||
valid_cert: false,
|
||||
};
|
||||
|
||||
vi.mock('panel/stores/dashboard', () => ({
|
||||
get dashboardState() {
|
||||
return mockDashboardState;
|
||||
},
|
||||
getUpdate: vi.fn(),
|
||||
getVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/stores/encryption', () => ({
|
||||
get encryptionState() {
|
||||
return mockEncryptionState;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('panel/common/intl', () => {
|
||||
const intl = {
|
||||
getMessage: (key: string, values?: any) => {
|
||||
const messages: Record<string, string> = {
|
||||
tls_certificate_expired: 'Your TLS certificate has expired',
|
||||
tls_certificate_expiring: 'Your TLS certificate is about to expire',
|
||||
update_available: `Version ${values?.version || ''} is available. Release notes`,
|
||||
update_button: 'Update',
|
||||
update_how_to: 'How to update',
|
||||
version_number: `Version ${values?.value || ''}`,
|
||||
check_updates_btn: 'Check for updates',
|
||||
};
|
||||
const msg = messages[key] || key;
|
||||
if (values?.a) {
|
||||
// When tag handlers are provided, return the full message
|
||||
return `Version ${values.version} is available. Release notes`;
|
||||
}
|
||||
return msg;
|
||||
},
|
||||
getUILanguage: () => 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
};
|
||||
return { default: intl };
|
||||
});
|
||||
|
||||
import { Banners } from 'panel/common/ui/Banners';
|
||||
import { BANNER_TEST_VALUES } from 'panel/helpers/banners';
|
||||
|
||||
const renderBanners = (props?: { forceBanner?: any }) => {
|
||||
return render(() => (
|
||||
<HashRouter>
|
||||
<Route path="/" component={() => <Banners {...props} />} />
|
||||
</HashRouter>
|
||||
));
|
||||
};
|
||||
|
||||
const resetStores = () => {
|
||||
mockDashboardState.isUpdateAvailable = false;
|
||||
mockDashboardState.newVersion = '';
|
||||
mockDashboardState.announcementUrl = '';
|
||||
mockDashboardState.canAutoUpdate = false;
|
||||
mockDashboardState.processingUpdate = false;
|
||||
mockDashboardState.processingVersion = false;
|
||||
mockDashboardState.dnsVersion = '';
|
||||
mockEncryptionState.enabled = false;
|
||||
mockEncryptionState.not_after = '';
|
||||
mockEncryptionState.valid_cert = false;
|
||||
};
|
||||
|
||||
describe('Banners', () => {
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
});
|
||||
|
||||
// ── Priority logic cases ──
|
||||
|
||||
it('shows TLS expired banner when cert is expired', () => {
|
||||
mockEncryptionState.enabled = true;
|
||||
mockEncryptionState.valid_cert = true;
|
||||
mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString(); // 1 day ago
|
||||
|
||||
renderBanners();
|
||||
|
||||
expect(screen.getByTestId('banner-tls-expired')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your TLS certificate has expired')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows TLS expiring banner when cert expires within 30 days', () => {
|
||||
mockEncryptionState.enabled = true;
|
||||
mockEncryptionState.valid_cert = true;
|
||||
mockEncryptionState.not_after = new Date(Date.now() + 15 * 86400000).toISOString(); // 15 days
|
||||
|
||||
renderBanners();
|
||||
|
||||
expect(screen.getByTestId('banner-tls-expiring')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your TLS certificate is about to expire')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows auto-update banner when update available and can auto-update', () => {
|
||||
mockEncryptionState.enabled = true;
|
||||
mockEncryptionState.valid_cert = true;
|
||||
mockEncryptionState.not_after = new Date(Date.now() + 60 * 86400000).toISOString(); // 60 days
|
||||
mockDashboardState.isUpdateAvailable = true;
|
||||
mockDashboardState.newVersion = 'v1.0.0';
|
||||
mockDashboardState.canAutoUpdate = true;
|
||||
|
||||
renderBanners();
|
||||
|
||||
expect(screen.getByTestId('banner-update-auto')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows manual-update banner when update available but cannot auto-update', () => {
|
||||
mockEncryptionState.enabled = true;
|
||||
mockEncryptionState.valid_cert = true;
|
||||
mockEncryptionState.not_after = new Date(Date.now() + 60 * 86400000).toISOString();
|
||||
mockDashboardState.isUpdateAvailable = true;
|
||||
mockDashboardState.newVersion = 'v1.0.0';
|
||||
mockDashboardState.canAutoUpdate = false;
|
||||
|
||||
renderBanners();
|
||||
|
||||
expect(screen.getByTestId('banner-update-manual')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows auto-update banner when TLS not enabled', () => {
|
||||
mockDashboardState.isUpdateAvailable = true;
|
||||
mockDashboardState.newVersion = 'v1.0.0';
|
||||
mockDashboardState.canAutoUpdate = true;
|
||||
|
||||
renderBanners();
|
||||
|
||||
expect(screen.getByTestId('banner-update-auto')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls through to update when not_after is invalid (NaN)', () => {
|
||||
mockEncryptionState.enabled = true;
|
||||
mockEncryptionState.valid_cert = true;
|
||||
mockEncryptionState.not_after = 'not-a-date';
|
||||
mockDashboardState.isUpdateAvailable = true;
|
||||
mockDashboardState.newVersion = 'v1.0.0';
|
||||
mockDashboardState.canAutoUpdate = true;
|
||||
|
||||
renderBanners();
|
||||
|
||||
expect(screen.getByTestId('banner-update-auto')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when no conditions met', () => {
|
||||
mockEncryptionState.enabled = true;
|
||||
mockEncryptionState.valid_cert = true;
|
||||
mockEncryptionState.not_after = new Date(Date.now() + 60 * 86400000).toISOString();
|
||||
|
||||
renderBanners();
|
||||
|
||||
expect(screen.queryByTestId('banner-root')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('TLS expired takes priority over update available', () => {
|
||||
mockEncryptionState.enabled = true;
|
||||
mockEncryptionState.valid_cert = true;
|
||||
mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString(); // expired
|
||||
mockDashboardState.isUpdateAvailable = true;
|
||||
mockDashboardState.newVersion = 'v1.0.0';
|
||||
mockDashboardState.canAutoUpdate = true;
|
||||
|
||||
renderBanners();
|
||||
|
||||
// Only TLS expired banner should appear
|
||||
expect(screen.getByTestId('banner-tls-expired')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('banner-update-auto')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Dismiss cases ──
|
||||
|
||||
it('hides the banner after clicking the close button', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockEncryptionState.enabled = true;
|
||||
mockEncryptionState.valid_cert = true;
|
||||
mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString(); // expired
|
||||
|
||||
renderBanners();
|
||||
|
||||
expect(screen.getByTestId('banner-tls-expired')).toBeInTheDocument();
|
||||
const closeButton = screen.getByTestId('banner-tls-expired-close');
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(screen.queryByTestId('banner-tls-expired')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('banner-root')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-shows the banner when the underlying condition changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockEncryptionState.enabled = true;
|
||||
mockEncryptionState.valid_cert = true;
|
||||
mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString(); // expired
|
||||
|
||||
renderBanners();
|
||||
|
||||
// Dismiss the expired banner
|
||||
const closeButton = screen.getByTestId('banner-tls-expired-close');
|
||||
await user.click(closeButton);
|
||||
expect(screen.queryByTestId('banner-tls-expired')).not.toBeInTheDocument();
|
||||
|
||||
// Change condition: now cert is valid but expiring soon
|
||||
mockEncryptionState.not_after = new Date(Date.now() + 15 * 86400000).toISOString(); // 15 days
|
||||
// Re-render
|
||||
renderBanners();
|
||||
|
||||
// Should now show the expiring banner
|
||||
expect(screen.getByTestId('banner-tls-expiring')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('banner-tls-expired')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── forceBanner (dev test override) ──
|
||||
|
||||
it('renders forced TLS expired banner regardless of store state', () => {
|
||||
renderBanners({ forceBanner: BANNER_TEST_VALUES.tlsExpired });
|
||||
expect(screen.getByTestId('banner-tls-expired')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders forced TLS expiring banner regardless of store state', () => {
|
||||
renderBanners({ forceBanner: BANNER_TEST_VALUES.tlsExpiring });
|
||||
expect(screen.getByTestId('banner-tls-expiring')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders forced auto-update banner', () => {
|
||||
renderBanners({ forceBanner: BANNER_TEST_VALUES.updateAuto });
|
||||
expect(screen.getByTestId('banner-update-auto')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders forced manual-update banner', () => {
|
||||
renderBanners({ forceBanner: BANNER_TEST_VALUES.updateManual });
|
||||
expect(screen.getByTestId('banner-update-manual')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forceBanner overrides real banner conditions', () => {
|
||||
// Set up a real TLS expired condition
|
||||
mockEncryptionState.enabled = true;
|
||||
mockEncryptionState.valid_cert = true;
|
||||
mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString();
|
||||
|
||||
// But force auto-update banner instead
|
||||
renderBanners({ forceBanner: BANNER_TEST_VALUES.updateAuto });
|
||||
|
||||
expect(screen.getByTestId('banner-update-auto')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('banner-tls-expired')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
121
client_v3/src/__tests__/common/ui/Footer.test.tsx
Normal file
121
client_v3/src/__tests__/common/ui/Footer.test.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@solidjs/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const mockDashboardState = {
|
||||
dnsVersion: '',
|
||||
processingVersion: true,
|
||||
theme: 'light',
|
||||
language: 'en',
|
||||
name: '',
|
||||
};
|
||||
|
||||
vi.mock('panel/stores/dashboard', () => ({
|
||||
get dashboardState() {
|
||||
return mockDashboardState;
|
||||
},
|
||||
getVersion: vi.fn(),
|
||||
changeTheme: vi.fn(),
|
||||
changeLanguage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/common/intl', () => {
|
||||
const intl = {
|
||||
getMessage: (key: string, values?: any) => {
|
||||
const messages: Record<string, string> = {
|
||||
privacy_policy: 'Privacy Policy',
|
||||
report_an_issue: 'Report an issue',
|
||||
release_notes: 'Release notes',
|
||||
system_theme: 'System',
|
||||
dark_theme: 'Dark',
|
||||
light_theme: 'Light',
|
||||
version_number: `Version ${values?.value || ''}`,
|
||||
check_updates_btn: 'Check for updates',
|
||||
};
|
||||
return messages[key] || key;
|
||||
},
|
||||
getUILanguage: () => 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
};
|
||||
return { default: intl };
|
||||
});
|
||||
|
||||
vi.mock('panel/lib/theme', () => ({
|
||||
default: {
|
||||
link: { link: 'linkClass', noDecoration: 'noDecorationClass' },
|
||||
dropdown: {
|
||||
menu: 'dropdownMenu',
|
||||
item: 'dropdownItem',
|
||||
item_active: 'dropdownItemActive',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { Footer } from 'panel/common/ui/Footer';
|
||||
import { getVersion } from 'panel/stores/dashboard';
|
||||
|
||||
describe('Footer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDashboardState.dnsVersion = '';
|
||||
mockDashboardState.processingVersion = true;
|
||||
});
|
||||
|
||||
it('hides version badge when dnsVersion is empty', () => {
|
||||
mockDashboardState.dnsVersion = '';
|
||||
|
||||
render(() => <Footer />);
|
||||
|
||||
expect(screen.queryByText(/Version/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows version badge when dnsVersion is populated', () => {
|
||||
mockDashboardState.dnsVersion = 'v1.0.0';
|
||||
|
||||
render(() => <Footer />);
|
||||
|
||||
expect(screen.getByText('Version v1.0.0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the check-updates button while processingVersion is true', () => {
|
||||
mockDashboardState.dnsVersion = 'v1.0.0';
|
||||
mockDashboardState.processingVersion = true;
|
||||
|
||||
render(() => <Footer />);
|
||||
|
||||
const button = screen.getByTestId('footer-check-updates');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables the check-updates button when processingVersion is false', () => {
|
||||
mockDashboardState.dnsVersion = 'v1.0.0';
|
||||
mockDashboardState.processingVersion = false;
|
||||
|
||||
render(() => <Footer />);
|
||||
|
||||
const button = screen.getByTestId('footer-check-updates');
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls getVersion(true) when check-updates button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDashboardState.dnsVersion = 'v1.0.0';
|
||||
mockDashboardState.processingVersion = false;
|
||||
|
||||
render(() => <Footer />);
|
||||
|
||||
const button = screen.getByTestId('footer-check-updates');
|
||||
await user.click(button);
|
||||
|
||||
expect(getVersion).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('has aria-label on the check-updates button', () => {
|
||||
mockDashboardState.dnsVersion = 'v1.0.0';
|
||||
|
||||
render(() => <Footer />);
|
||||
|
||||
const button = screen.getByTestId('footer-check-updates');
|
||||
expect(button.getAttribute('aria-label')).toBe('Check for updates');
|
||||
});
|
||||
});
|
||||
|
|
@ -7,17 +7,4 @@ describe('Toast', () => {
|
|||
const { getByText } = render(() => <Toast id="1" message="hello" type="success" />);
|
||||
expect(getByText('hello')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders interpolated options.components content', () => {
|
||||
const { container } = render(() => (
|
||||
<Toast
|
||||
id="2"
|
||||
message="update_failed"
|
||||
type="notice"
|
||||
options={{ components: { a: (c: string) => c } }}
|
||||
/>
|
||||
));
|
||||
// Message rendered (not [object Object])
|
||||
expect(container.textContent).not.toContain('[object Object]');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,20 +13,13 @@ describe('toasts store', () => {
|
|||
toastsState.notices.forEach((n: any) => removeToast(n.id));
|
||||
});
|
||||
|
||||
it('addNoticeToast extracts error.toString() (no [object Object])', () => {
|
||||
addNoticeToast({ error: 'update_failed' });
|
||||
it('addNoticeToast stores the message', () => {
|
||||
addNoticeToast('update_failed');
|
||||
const last = toastsState.notices[toastsState.notices.length - 1];
|
||||
expect(last.message).toBe('update_failed');
|
||||
expect(last.type).toBe('notice');
|
||||
});
|
||||
|
||||
it('addNoticeToast preserves options', () => {
|
||||
const options = { components: { a: () => {} } };
|
||||
addNoticeToast({ error: 'update_failed', options });
|
||||
const last = toastsState.notices[toastsState.notices.length - 1];
|
||||
expect(last.options).toEqual(options);
|
||||
});
|
||||
|
||||
it('addErrorToast preserves options and action', () => {
|
||||
const action = { text: 'retry', callback: () => {} };
|
||||
addErrorToast({ error: 'boom', options: { x: 1 }, action });
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@
|
|||
--stroke-toplines-close-icon-hovered: var(--gray-10);
|
||||
--stroke-toplines-close-icon-pressed: var(--gray-20);
|
||||
--fills-backgrounds-recent-activity: var(--product-primary-90);
|
||||
--fills-backgrounds-recent-activity-changed: var(--orange-90);
|
||||
--fills-backgrounds-recent-activity-blocked: var(--red-90);
|
||||
/* Switch */
|
||||
--fills-switch-on-default: var(--product-primary-50);
|
||||
--fills-switch-on-hovered: var(--product-primary-60);
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@
|
|||
--stroke-toplines-close-icon-hovered: var(--gray-10);
|
||||
--stroke-toplines-close-icon-pressed: var(--gray-20);
|
||||
--fills-backgrounds-recent-activity: var(--product-primary-10);
|
||||
--fills-backgrounds-recent-activity-changed: var(--orange-10);
|
||||
--fills-backgrounds-recent-activity-blocked: var(--red-10);
|
||||
/* Switch */
|
||||
--fills-switch-on-default: var(--product-primary-50);
|
||||
--fills-switch-on-hovered: var(--product-primary-60);
|
||||
|
|
|
|||
71
client_v3/src/common/ui/Banner/Banner.module.pcss
Normal file
71
client_v3/src/common/ui/Banner/Banner.module.pcss
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
.banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px 42px 16px 16px;
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 8px 16px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
width: auto;
|
||||
min-width: 110px;
|
||||
max-width: max-content;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--default-gray-icons);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
position: static;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--product-primary-90);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.closeIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: var(--page-background-additional);
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-color: var(--fills-backgrounds-recent-activity-changed);
|
||||
}
|
||||
|
||||
.critical {
|
||||
background-color: var(--fills-backgrounds-recent-activity-blocked);
|
||||
}
|
||||
40
client_v3/src/common/ui/Banner/Banner.tsx
Normal file
40
client_v3/src/common/ui/Banner/Banner.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { type JSX } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
|
||||
import s from './Banner.module.pcss';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
export type BannerVariant = 'info' | 'warning' | 'critical';
|
||||
|
||||
type Props = {
|
||||
variant: BannerVariant;
|
||||
message: JSX.Element;
|
||||
action?: JSX.Element;
|
||||
onClose?: () => void;
|
||||
'data-testid'?: string;
|
||||
};
|
||||
|
||||
export const Banner = (props: Props) => (
|
||||
<div
|
||||
class={cn(s.banner, s[props.variant])}
|
||||
role={props.variant === 'critical' ? 'alert' : 'status'}
|
||||
aria-live={props.variant === 'critical' ? 'assertive' : 'polite'}
|
||||
data-testid={props['data-testid']}
|
||||
>
|
||||
<div class={cn(s.message, theme.text.t3)}>{props.message}</div>
|
||||
{props.action && <div class={s.action}>{props.action}</div>}
|
||||
{props.onClose && (
|
||||
<button
|
||||
type="button"
|
||||
class={s.close}
|
||||
onClick={props.onClose}
|
||||
aria-label="Close notification"
|
||||
data-testid={`${props['data-testid']}-close`}
|
||||
>
|
||||
<Icon icon="cross" class={s.closeIcon} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
2
client_v3/src/common/ui/Banner/index.ts
Normal file
2
client_v3/src/common/ui/Banner/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Banner } from './Banner';
|
||||
export type { BannerVariant } from './Banner';
|
||||
9
client_v3/src/common/ui/Banners/Banners.module.pcss
Normal file
9
client_v3/src/common/ui/Banners/Banners.module.pcss
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.actionButton {
|
||||
width: auto;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.actionLink {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
196
client_v3/src/common/ui/Banners/Banners.tsx
Normal file
196
client_v3/src/common/ui/Banners/Banners.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { Show, createMemo, createSignal, type JSX } from 'solid-js';
|
||||
import { useNavigate, useSearchParams } from '@solidjs/router';
|
||||
|
||||
import { Banner } from 'panel/common/ui/Banner';
|
||||
import { Button } from 'panel/common/ui/Button';
|
||||
import { MANUAL_UPDATE_LINK } from 'panel/helpers/constants';
|
||||
import { Paths } from 'panel/components/Routes/Paths';
|
||||
import intl from 'panel/common/intl';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import { dashboardState, getUpdate } from 'panel/stores/dashboard';
|
||||
import { encryptionState } from 'panel/stores/encryption';
|
||||
import {
|
||||
type BannerSpec,
|
||||
type BannerType,
|
||||
bannerSpecsEqual,
|
||||
BANNER_TEST_VALUES,
|
||||
} from 'panel/helpers/banners';
|
||||
|
||||
import s from './Banners.module.pcss';
|
||||
|
||||
const TLS_EXPIRY_WARNING_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type Props = {
|
||||
forceBanner?: BannerSpec;
|
||||
};
|
||||
|
||||
export const Banners = (props: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams<{ forceBanner?: string }>();
|
||||
const [dismissed, setDismissed] = createSignal<BannerSpec | null>(null);
|
||||
|
||||
const forceFromQuery = createMemo(() => {
|
||||
const key = searchParams.forceBanner;
|
||||
if (key && key in BANNER_TEST_VALUES) {
|
||||
return BANNER_TEST_VALUES[key as BannerType];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const computeActiveBanner = (): BannerSpec | null => {
|
||||
if (encryptionState.enabled && encryptionState.valid_cert && encryptionState.not_after) {
|
||||
const expiry = new Date(encryptionState.not_after).getTime();
|
||||
if (!Number.isNaN(expiry)) {
|
||||
if (Date.now() > expiry) {
|
||||
return { type: 'tls_expired' };
|
||||
}
|
||||
if (Date.now() > expiry - TLS_EXPIRY_WARNING_MS) {
|
||||
return { type: 'tls_expiring' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dashboardState.isUpdateAvailable) {
|
||||
const spec = {
|
||||
version: dashboardState.newVersion,
|
||||
announcementUrl: dashboardState.announcementUrl,
|
||||
};
|
||||
return dashboardState.canAutoUpdate
|
||||
? { type: 'update_auto', ...spec }
|
||||
: { type: 'update_manual', ...spec };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const banner = createMemo<BannerSpec | null>(() => {
|
||||
const active = props.forceBanner ?? forceFromQuery() ?? computeActiveBanner();
|
||||
if (!active) return null;
|
||||
const dismissedValue = dismissed();
|
||||
if (dismissedValue && bannerSpecsEqual(dismissedValue, active)) {
|
||||
return null;
|
||||
}
|
||||
return active;
|
||||
});
|
||||
|
||||
const announcementLinkHandler = (announcementUrl: string) => (text: string) => (
|
||||
<a href={announcementUrl} class={theme.link.link} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show when={banner()}>
|
||||
{(spec) => {
|
||||
const current = spec();
|
||||
|
||||
const renderBanner = (): JSX.Element => {
|
||||
switch (current.type) {
|
||||
case 'tls_expired':
|
||||
return (
|
||||
<Banner
|
||||
variant="critical"
|
||||
message={intl.getMessage('tls_certificate_expired')}
|
||||
action={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="very-small"
|
||||
compact
|
||||
onClick={() => navigate(Paths.Encryption)}
|
||||
class={s.actionButton}
|
||||
>
|
||||
{intl.getMessage('update_button')}
|
||||
</Button>
|
||||
}
|
||||
onClose={() => setDismissed(current)}
|
||||
data-testid="banner-tls-expired"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'tls_expiring':
|
||||
return (
|
||||
<Banner
|
||||
variant="warning"
|
||||
message={intl.getMessage('tls_certificate_expiring')}
|
||||
action={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="very-small"
|
||||
compact
|
||||
onClick={() => navigate(Paths.Encryption)}
|
||||
class={s.actionButton}
|
||||
>
|
||||
{intl.getMessage('update_button')}
|
||||
</Button>
|
||||
}
|
||||
onClose={() => setDismissed(current)}
|
||||
data-testid="banner-tls-expiring"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'update_auto':
|
||||
return (
|
||||
<Banner
|
||||
variant="info"
|
||||
message={intl.getMessage('update_available', {
|
||||
version: current.version,
|
||||
a: announcementLinkHandler(current.announcementUrl),
|
||||
})}
|
||||
action={
|
||||
<Button
|
||||
variant="primary"
|
||||
size="very-small"
|
||||
compact
|
||||
disabled={dashboardState.processingUpdate}
|
||||
onClick={() => getUpdate()}
|
||||
class={s.actionButton}
|
||||
>
|
||||
{intl.getMessage('update_button')}
|
||||
</Button>
|
||||
}
|
||||
onClose={() => setDismissed(current)}
|
||||
data-testid="banner-update-auto"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'update_manual':
|
||||
return (
|
||||
<Banner
|
||||
variant="info"
|
||||
message={intl.getMessage('update_available', {
|
||||
version: current.version,
|
||||
a: announcementLinkHandler(current.announcementUrl),
|
||||
})}
|
||||
action={
|
||||
<a
|
||||
href={MANUAL_UPDATE_LINK}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={s.actionLink}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="very-small"
|
||||
compact
|
||||
class={s.actionButton}
|
||||
>
|
||||
{intl.getMessage('update_how_to')}
|
||||
</Button>
|
||||
</a>
|
||||
}
|
||||
onClose={() => setDismissed(current)}
|
||||
data-testid="banner-update-manual"
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return <div data-testid="banner-root">{renderBanner()}</div>;
|
||||
}}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
3
client_v3/src/common/ui/Banners/index.ts
Normal file
3
client_v3/src/common/ui/Banners/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { Banners } from './Banners';
|
||||
export { BANNER_TEST_VALUES, getForceBannerFromQuery } from 'panel/helpers/banners';
|
||||
export type { BannerSpec, BannerType } from 'panel/helpers/banners';
|
||||
|
|
@ -36,6 +36,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.height_xs {
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.height_s {
|
||||
height: 48px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,55 @@
|
|||
import { type JSX, splitProps } from 'solid-js';
|
||||
import { type JSX, splitProps, Show } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import s from './Button.module.pcss';
|
||||
|
||||
export type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
size?: 'small' | 'medium' | 'big';
|
||||
size?: 'very-small' | 'small' | 'medium' | 'big';
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'secondary-danger';
|
||||
leftAddon?: JSX.Element;
|
||||
rightAddon?: JSX.Element;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Button = (props: ButtonProps) => {
|
||||
const [local, rest] = splitProps(props, [
|
||||
'id',
|
||||
'size',
|
||||
'type',
|
||||
'variant',
|
||||
'children',
|
||||
'size',
|
||||
'class',
|
||||
'className',
|
||||
'onClick',
|
||||
'children',
|
||||
'disabled',
|
||||
'leftAddon',
|
||||
'rightAddon',
|
||||
'compact',
|
||||
]);
|
||||
|
||||
return (
|
||||
<button
|
||||
id={local.id}
|
||||
type={local.type || 'button'}
|
||||
{...rest}
|
||||
type={props.type || 'button'}
|
||||
disabled={local.disabled}
|
||||
class={cn(
|
||||
s.button,
|
||||
s[local.variant || 'primary'],
|
||||
{
|
||||
[s.height_s]: (local.size || 'medium') === 'small',
|
||||
[s.height_m]: (local.size || 'medium') === 'medium',
|
||||
[s.height_l]: (local.size || 'medium') === 'big',
|
||||
[s.height_xs]: local.size === 'very-small',
|
||||
[s.height_s]: local.size === 'small',
|
||||
[s.height_m]: local.size === 'medium',
|
||||
[s.height_l]: local.size === 'big',
|
||||
},
|
||||
local.class,
|
||||
local.className,
|
||||
)}
|
||||
onClick={(e) => (local.onClick as any)?.(e)}
|
||||
disabled={local.disabled}
|
||||
{...rest}
|
||||
>
|
||||
<div class={s.leftAddon}>{local.leftAddon}</div>
|
||||
<Show when={local.leftAddon || !local.compact}>
|
||||
<div class={s.leftAddon}>{local.leftAddon}</div>
|
||||
</Show>
|
||||
{local.children}
|
||||
<div class={s.rightAddon}>{local.rightAddon}</div>
|
||||
<Show when={local.rightAddon || !local.compact}>
|
||||
<div class={s.rightAddon}>{local.rightAddon}</div>
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createSignal, createMemo, For } from 'solid-js';
|
||||
import { createSignal, createMemo, For, Show } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import theme from 'panel/lib/theme';
|
||||
|
|
@ -11,7 +11,11 @@ import { LanguageDropdown } from '../LanguageDropdown/LanguageDropdown';
|
|||
import { REPOSITORY, PRIVACY_POLICY_LINK, THEMES } from 'panel/helpers/constants';
|
||||
import { LANGUAGES, LANGUAGE_NAMES } from 'panel/helpers/twosky';
|
||||
import { setHtmlLangAttr, setUITheme } from 'panel/helpers/helpers';
|
||||
import { changeTheme, changeLanguage as changeLanguageAction } from 'panel/stores/dashboard';
|
||||
import {
|
||||
changeTheme,
|
||||
changeLanguage as changeLanguageAction,
|
||||
getVersion,
|
||||
} from 'panel/stores/dashboard';
|
||||
import { dashboardState } from 'panel/stores/dashboard';
|
||||
|
||||
import s from './styles.module.pcss';
|
||||
|
|
@ -70,71 +74,92 @@ export const Footer = () => {
|
|||
return (
|
||||
<footer class={s.footer}>
|
||||
<div class={s.container}>
|
||||
<div class={s.copyright}>© 2018–{getYear()} AdGuard Home</div>
|
||||
<div class={s.leftGroup}>
|
||||
<div class={s.copyright}>© 2018–{getYear()} AdGuard Home</div>
|
||||
|
||||
<div class={s.links}>
|
||||
<For each={linksData()}>
|
||||
{({ name, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
class={cn(theme.link.link, theme.link.noDecoration)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Show when={dashboardState.dnsVersion}>
|
||||
<div class={s.version}>
|
||||
{intl.getMessage('version_number', {
|
||||
value: dashboardState.dnsVersion,
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class={cn(s.checkUpdateBtn, {
|
||||
[s.checkUpdateBtn_loading]: dashboardState.processingVersion,
|
||||
})}
|
||||
aria-label={intl.getMessage('check_updates_btn')}
|
||||
disabled={dashboardState.processingVersion}
|
||||
data-testid="footer-check-updates"
|
||||
onClick={() => getVersion(true)}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class={s.column}>
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
open={themeDropdownOpen()}
|
||||
onOpenChange={setThemeDropdownOpen}
|
||||
menu={
|
||||
<div class={theme.dropdown.menu}>
|
||||
<For each={Object.values(THEMES)}>
|
||||
{(v) => (
|
||||
<button
|
||||
type="button"
|
||||
class={cn(theme.dropdown.item, {
|
||||
[theme.dropdown.item_active]: currentTheme() === v,
|
||||
})}
|
||||
onClick={() => onThemeChange(v)}
|
||||
>
|
||||
{themeTranslations()[v]}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
}
|
||||
class={s.dropdown}
|
||||
position="bottomRight"
|
||||
>
|
||||
<div class={s.dropdownTrigger}>
|
||||
<Icon icon={getThemeIcon() as any} class={s.icon} />
|
||||
<span>
|
||||
{
|
||||
themeTranslations()[
|
||||
isLoggedIn() ? currentTheme() : currentThemeLocal()
|
||||
]
|
||||
}
|
||||
</span>
|
||||
<Icon
|
||||
icon={dashboardState.processingVersion ? 'loader' : 'refresh'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Show>
|
||||
|
||||
<div class={s.links}>
|
||||
<For each={linksData()}>
|
||||
{({ name, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
class={cn(theme.link.link, theme.link.noDecoration)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={s.column}>
|
||||
<LanguageDropdown
|
||||
value={currentLanguage()}
|
||||
languages={LANGUAGES}
|
||||
languageNames={LANGUAGE_NAMES}
|
||||
onChange={(lang: string) => changeLanguage(lang as LocalesType)}
|
||||
class={s.dropdown}
|
||||
position="bottomRight"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
open={themeDropdownOpen()}
|
||||
onOpenChange={setThemeDropdownOpen}
|
||||
menu={
|
||||
<div class={theme.dropdown.menu}>
|
||||
<For each={Object.values(THEMES)}>
|
||||
{(v) => (
|
||||
<button
|
||||
type="button"
|
||||
class={cn(theme.dropdown.item, {
|
||||
[theme.dropdown.item_active]: currentTheme() === v,
|
||||
})}
|
||||
onClick={() => onThemeChange(v)}
|
||||
>
|
||||
{themeTranslations()[v]}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
}
|
||||
class={s.dropdown}
|
||||
position="bottomRight"
|
||||
>
|
||||
<div class={s.dropdownTrigger}>
|
||||
<Icon icon={getThemeIcon() as any} class={s.icon} />
|
||||
<span>
|
||||
{
|
||||
themeTranslations()[
|
||||
isLoggedIn() ? currentTheme() : currentThemeLocal()
|
||||
]
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
<LanguageDropdown
|
||||
value={currentLanguage()}
|
||||
languages={LANGUAGES}
|
||||
languageNames={LANGUAGE_NAMES}
|
||||
onChange={(lang: string) => changeLanguage(lang as LocalesType)}
|
||||
class={s.dropdown}
|
||||
position="bottomRight"
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,80 @@
|
|||
}
|
||||
}
|
||||
|
||||
.leftGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
column-gap: 40px;
|
||||
row-gap: 24px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.copyright {
|
||||
order: 1;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
order: 2;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.checkUpdateBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
color: var(--default-product-icon);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--page-background-additional);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--product-primary-90);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkUpdateBtn_loading {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -34,7 +108,7 @@
|
|||
@media (min-width: 1024px) {
|
||||
flex-wrap: nowrap;
|
||||
gap: 24px;
|
||||
margin-right: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
|
|
@ -46,6 +120,8 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdownTrigger {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Sidebar } from 'panel/common/ui/Sidebar';
|
|||
import { Icons } from 'panel/common/ui/Icons';
|
||||
import { Footer } from 'panel/common/ui/Footer';
|
||||
import { Header } from 'panel/common/ui/Header';
|
||||
import { Banners } from 'panel/common/ui/Banners';
|
||||
import { Settings } from 'panel/components/Settings';
|
||||
import intl, { LocalesType } from 'panel/common/intl';
|
||||
import { Encryption } from 'panel/components/Encryption';
|
||||
|
|
@ -103,6 +104,8 @@ const App = () => {
|
|||
<>
|
||||
<Header />
|
||||
|
||||
<Banners />
|
||||
|
||||
<div class={s.wrapper}>
|
||||
<Sidebar />
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ export const Settings = () => {
|
|||
|
||||
const [searchParams] = useSearchParams<{ [SCROLL_QUERY_KEY]?: string }>();
|
||||
|
||||
// Handle query-param-based scroll to section
|
||||
createEffect(() => {
|
||||
if (!isLoading()) {
|
||||
const section = searchParams[SCROLL_QUERY_KEY];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { type JSX, onMount, onCleanup, Show } from 'solid-js';
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
import cn from 'clsx';
|
||||
import intl from 'panel/common/intl';
|
||||
import { getUndoCallback, clearUndoCallback } from 'panel/stores/toasts';
|
||||
import { TOAST_TIMEOUTS } from '../../helpers/constants';
|
||||
|
||||
|
|
@ -23,9 +22,6 @@ type ToastProps = {
|
|||
undoId?: string;
|
||||
action?: ToastAction;
|
||||
code?: string;
|
||||
options?: {
|
||||
components?: Record<string, (children: string) => JSX.Element>;
|
||||
};
|
||||
};
|
||||
|
||||
const Toast = (props: ToastProps) => {
|
||||
|
|
@ -82,13 +78,6 @@ const Toast = (props: ToastProps) => {
|
|||
removeCurrentToast();
|
||||
};
|
||||
|
||||
const messageContent = () => {
|
||||
if (props.options?.components) {
|
||||
return intl.getMessage(String(props.message), props.options.components);
|
||||
}
|
||||
return props.message;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class={s.toast}
|
||||
|
|
@ -104,7 +93,7 @@ const Toast = (props: ToastProps) => {
|
|||
class={cn(s.icon, s[props.type])}
|
||||
/>
|
||||
|
||||
<div class={s.content}>{messageContent()}</div>
|
||||
<div class={s.content}>{props.message}</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.actionLabel}>
|
||||
|
|
|
|||
62
client_v3/src/helpers/banners.ts
Normal file
62
client_v3/src/helpers/banners.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
export type BannerSpec =
|
||||
| { type: 'tls_expired' }
|
||||
| { type: 'tls_expiring' }
|
||||
| { type: 'update_auto'; version: string; announcementUrl: string }
|
||||
| { type: 'update_manual'; version: string; announcementUrl: string };
|
||||
|
||||
/** Keys matching the query param values for each banner type. */
|
||||
export type BannerType = 'tlsExpired' | 'tlsExpiring' | 'updateAuto' | 'updateManual';
|
||||
|
||||
/** Pre-built banner specs for visual QA. Use via `<Banners forceBanner={...}>` or `?forceBanner=updateAuto`. */
|
||||
export const BANNER_TEST_VALUES: Record<BannerType, BannerSpec> = {
|
||||
tlsExpired: { type: 'tls_expired' },
|
||||
tlsExpiring: { type: 'tls_expiring' },
|
||||
updateAuto: {
|
||||
type: 'update_auto',
|
||||
version: '1.0',
|
||||
announcementUrl: 'https://github.com/AdguardTeam/AdGuardHome/releases',
|
||||
},
|
||||
updateManual: {
|
||||
type: 'update_manual',
|
||||
version: '1.0',
|
||||
announcementUrl: 'https://github.com/AdguardTeam/AdGuardHome/releases',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads `?forceBanner=<key>` from the current URL query string.
|
||||
* Returns the matching banner spec, or null if the param is absent or invalid.
|
||||
*
|
||||
* Example: `http://localhost:3000/?forceBanner=tlsExpired` → `{ type: 'tls_expired' }`
|
||||
*/
|
||||
export const getForceBannerFromQuery = (): BannerSpec | null => {
|
||||
// Check both query sources: before hash and inside hash fragment.
|
||||
// HashRouter: http://localhost/#/dashboard?forceBanner=tlsExpired
|
||||
const sources = [window.location.search, window.location.hash.split('?')[1]].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
|
||||
for (const src of sources) {
|
||||
const key = new URLSearchParams(src).get('forceBanner');
|
||||
if (key && key in BANNER_TEST_VALUES) {
|
||||
return BANNER_TEST_VALUES[key as BannerType];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether two banner specs represent the same logical banner.
|
||||
* For TLS banners, only the type matters.
|
||||
* For update banners, the version and announcement URL must also match.
|
||||
*/
|
||||
export const bannerSpecsEqual = (a: BannerSpec, b: BannerSpec | null): b is BannerSpec => {
|
||||
if (!b) return false;
|
||||
if (a.type !== b.type) return false;
|
||||
if (a.type === 'update_auto' || a.type === 'update_manual') {
|
||||
const update = b as typeof a;
|
||||
return a.version === update.version && a.announcementUrl === update.announcementUrl;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
|
@ -3,10 +3,10 @@ import { untrack } from 'solid-js';
|
|||
import { STANDARD_DNS_PORT, STANDARD_WEB_PORT } from 'panel/helpers/constants';
|
||||
import { areEqualVersions } from 'panel/helpers/version';
|
||||
import { apiClient } from 'panel/api/Api';
|
||||
import { LocalesType } from 'panel/common/intl';
|
||||
import intl, { LocalesType } from 'panel/common/intl';
|
||||
import { addErrorToast, addSuccessToast, addNoticeToast } from './toasts';
|
||||
import { getTlsStatus } from './encryption';
|
||||
import { updateFailedNoticeOptions } from './dashboard/noticeOptions';
|
||||
import { getUpdateFailedMessage } from './dashboard/noticeOptions';
|
||||
import type { Client, AutoClient } from 'panel/initialState';
|
||||
|
||||
type DashboardState = {
|
||||
|
|
@ -212,9 +212,9 @@ export const getVersion = async (recheck = false) => {
|
|||
}
|
||||
if (recheck) {
|
||||
if (data && !areEqualVersions(currentVersion, data.new_version)) {
|
||||
addSuccessToast('updates_checked');
|
||||
addSuccessToast(intl.getMessage('updates_checked'));
|
||||
} else {
|
||||
addSuccessToast('updates_version_equal');
|
||||
addSuccessToast(intl.getMessage('updates_version_equal'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -226,7 +226,7 @@ export const getVersion = async (recheck = false) => {
|
|||
export const getUpdate = async () => {
|
||||
setState('processingUpdate', true);
|
||||
const handleRequestError = () => {
|
||||
addNoticeToast({ error: 'update_failed', options: updateFailedNoticeOptions });
|
||||
addNoticeToast(getUpdateFailedMessage());
|
||||
setState('processingUpdate', false);
|
||||
};
|
||||
const handleRequestSuccess = (response: any) => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import intl from 'panel/common/intl';
|
||||
import { MANUAL_UPDATE_LINK } from 'panel/helpers/constants';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
/**
|
||||
* Options for the update_failed notice toast, providing a clickable
|
||||
* hyperlink to the manual update instructions.
|
||||
*/
|
||||
export const updateFailedNoticeOptions = {
|
||||
components: {
|
||||
a: (children: string) => (
|
||||
<a href={MANUAL_UPDATE_LINK} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
export const getUpdateFailedMessage = () =>
|
||||
intl.getMessage('update_failed', {
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href={MANUAL_UPDATE_LINK}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={theme.link.link}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -308,6 +308,7 @@ export const editFilter = async (url: string, data: any, whitelist: boolean) =>
|
|||
try {
|
||||
await apiClient.setFilterUrl({ url, data, whitelist });
|
||||
setState({ processingConfigFilter: false, isModalOpen: false });
|
||||
addSuccessToast(intl.getMessage('changes_saved_success'));
|
||||
await getFilteringStatus();
|
||||
} catch (error) {
|
||||
addErrorToast({ error });
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export const addRewrite = async (config: RewriteConfig) => {
|
|||
await apiClient.addRewrite(config);
|
||||
setState('processingAdd', false);
|
||||
toggleRewritesModal();
|
||||
addSuccessToast(intl.getMessage('changes_saved_success'));
|
||||
await getRewritesList();
|
||||
} catch (error) {
|
||||
addErrorToast({ error });
|
||||
|
|
@ -86,6 +87,7 @@ export const updateRewrite = async (
|
|||
if (options.closeModal !== false) {
|
||||
toggleRewritesModal();
|
||||
}
|
||||
addSuccessToast(intl.getMessage('changes_saved_success'));
|
||||
await getRewritesList();
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -80,14 +80,12 @@ export const addSuccessToast = (message: any) => {
|
|||
setState('notices', (prev) => [...prev, notice]);
|
||||
};
|
||||
|
||||
export const addNoticeToast = (payload: { error: any; options?: any }) => {
|
||||
const { error, options } = payload;
|
||||
export const addNoticeToast = (message: any) => {
|
||||
setState('notices', (prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nanoid(),
|
||||
message: error.toString(),
|
||||
options,
|
||||
message,
|
||||
type: 'notice' as const,
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue