AGH-177 design review UI fixes

This commit is contained in:
Ildar Kamalov
2026-09-25 15:53:56 +03:00
committed by GitHub
parent c0a26e3762
commit 27fbabe7cf
208 changed files with 5717 additions and 1820 deletions
+12
View File
@@ -324,6 +324,10 @@ must not depend on stores or components. Helpers are pure and dependency-free.
'./Foo.module.pcss'` — and reference classes as `s.button`, `s[variant]`.
Compose conditional classes with `clsx`, imported as `cn`:
`import cn from 'clsx'` (e.g. `cn(s.button, s.primary, { [s.active]: on() })`).
- **Hover styles**: Wrap `:hover` styling in `@media (hover: hover)` so touch
devices do not keep a stuck highlight after a tap. Gate `:hover` together
with `:focus` on row links, since a tap also focuses an `<a>` in Chrome;
leave `:focus-visible` outlines ungated for keyboard users.
- **Colors**: Never hardcode color hex values in `.pcss` or inline styles.
Always reference the CSS custom properties defined in
`src/common/styles/vars.css` and `src/common/styles/colors/*.css`
@@ -346,6 +350,14 @@ must not depend on stores or components. Helpers are pure and dependency-free.
a conditional class via `cn(...)`, not an inline `style`. Exception: a single
computed pixel offset for a dragging/positioning edge case may be acceptable
when no class-based solution exists — justify it in a comment.
- **Comments**: Write comments only where they earn their place, and keep
them useful and compact — one or two lines explaining a non-obvious
*why*: a workaround, a browser quirk, or a cross-file constraint a
reader would otherwise break. Do not narrate what a rule or function
does, restate selector/property names, or spell out measurements,
arithmetic, and design history; that belongs with the value it
constrains or in the PR description. Applies to `.pcss`, `.tsx`, and
tests alike. Delete a comment once the code it describes changes.
- **Reactivity**: Use `createSignal` for local state, `createMemo` for
derived values, `createEffect` for side effects, `onMount` for initial data
fetches, and `onCleanup` for teardown. Do not read signals imperatively
+7 -7
View File
@@ -460,11 +460,11 @@
"ip_address": "عنوان IP",
"known_tracker": "متعقب معروف",
"language": "اللغة",
"last_days": "| آخر 0 يوم | آخر يوم واحد | آخر يومين | آخر %count% أيام | آخر %count% يومًا | آخر %count% يوم",
"last_hours": "| آخر 0 ساعة | آخر ساعة واحدة | آخر ساعتين | آخر %count% ساعات | آخر %count% ساعة | آخر %count% ساعة",
"last_days": "| آخر يوم واحد | آخر يومين | آخر %count% أيام | آخر %count% يومًا | آخر %count% يوم",
"last_hours": "| آخر ساعة واحدة | آخر ساعتين | آخر %count% ساعات | آخر %count% ساعة | آخر %count% ساعة",
"last_updated_label": "آخر تحديث",
"light_theme": "المظهر فاتح",
"list_updated": "| لا توجد قوائم محدثة | تم تحديث قائمة واحدة | تم تحديث قائمتين | تم تحديث %count% قوائم | تم تحديث %count% قائمة | تم تحديث %count% قائمة",
"list_updated": "| تم تحديث قائمة واحدة | تم تحديث قائمتين | تم تحديث %count% قوائم | تم تحديث %count% قائمة | تم تحديث %count% قائمة",
"login": "تسجيل الدخول",
"logout": "تسجيل خروج",
"make_static": "اجعلها ثابتة",
@@ -513,8 +513,8 @@
"password_requirements_uppercase": "على الأقل 1 حرف كبير",
"path_to_file_placeholder": "طريق الملف: عنوان محلي أو عنوان على الشبكة العنكبوتية",
"pause_for_hour": "إيقاف مؤقت لمدة %count% ساعة",
"pause_for_minutes": "| إيقاف مؤقت لمدة 0 دقيقة | إيقاف مؤقت لمدة دقيقة واحدة | إيقاف مؤقت لمدة دقيقتين | إيقاف مؤقت لمدة %count% دقائق | إيقاف مؤقت لمدة %count% دقيقة | إيقاف مؤقت لمدة %count% دقيقة",
"pause_for_seconds": "| إيقاف مؤقت لمدة 0 ثانية | إيقاف مؤقت لمدة ثانية واحدة | إيقاف مؤقت لمدة ثانيتين | إيقاف مؤقت لمدة %count% ثوانٍ | إيقاف مؤقت لمدة %count% ثانية | إيقاف مؤقت لمدة %count% ثانية",
"pause_for_minutes": "| إيقاف مؤقت لمدة دقيقة واحدة | إيقاف مؤقت لمدة دقيقتين | إيقاف مؤقت لمدة %count% دقائق | إيقاف مؤقت لمدة %count% دقيقة | إيقاف مؤقت لمدة %count% دقيقة",
"pause_for_seconds": "| إيقاف مؤقت لمدة ثانية واحدة | إيقاف مؤقت لمدة ثانيتين | إيقاف مؤقت لمدة %count% ثوانٍ | إيقاف مؤقت لمدة %count% ثانية | إيقاف مؤقت لمدة %count% ثانية",
"pause_until_tomorrow": "إيقاف مؤقت حتى الغد، %time%",
"period_notify": "لتغيير المدة الزمنية المعروضة، قم بتحرير <i>الاحتفاظ بالاحصائيات</i> في <a>إعدادات الوضع العام</a>",
"plain_dns": "عنوان DNS العادي",
@@ -635,14 +635,14 @@
"settings_confirm_decrease_stats_rotation_interval": "تقليل فترة الاحتفاظ بالإحصائيات؟",
"settings_confirm_decrease_stats_rotation_interval_desc": "سيؤدي هذا إلى حذف جميع الاحصائيات الأقدم من %value%",
"settings_custom": "مخصص",
"settings_days": "| 0 يوم | يوم واحد | يومان | %count% أيام | %count% يومًا | %count% يوم",
"settings_days": "| يوم واحد | يومان | %count% أيام | %count% يومًا | %count% يوم",
"settings_domain_names": "أسماء النطاقات، واحد في كل سطر",
"settings_filter_requests": "تصفية الطلبات",
"settings_filter_requests_desc": "يمكنك استخدام <a>Blocklists</a> و <b>Allowlists</b> و <c>قواعد المستخدم</c> لإعداد قواعد التصفية",
"settings_filtering_and_security": "التصفية والأمان",
"settings_general_short": "الوضع العام",
"settings_global": "عالمي",
"settings_hours": "| 0 ساعة | ساعة واحدة | ساعتان | %count% ساعات | %count% ساعة | %count% ساعة",
"settings_hours": "| ساعة واحدة | ساعتان | %count% ساعات | %count% ساعة | %count% ساعة",
"settings_log_rotation_hours": "تدوير السجل، بالساعات",
"settings_notify_changes_saved": "تم حفظ التغييرات",
"settings_notify_query_log_cleared": "تم مسح سجل الاستعلام",
+9 -16
View File
@@ -3,7 +3,6 @@
"add_allowlist": "Add allowlist",
"add_blocklist": "Add blocklist",
"add_persistent_client": "Add as persistent client",
"add_to_allowlist": "Add to allowlist",
"ads_blocked": "Blocked ads",
"ads_blocked_card": "Blocked ads",
"ads_blocked_tooltip": "The number of DNS queries blocked by adblock filters and hosts blocklist",
@@ -12,7 +11,6 @@
"all_lists_up_to_date_toast": "All lists are already up-to-date",
"all_rewrites_disabled": "All custom DNS responses will be disabled",
"all_rewrites_enabled": "All custom DNS responses will be enabled",
"allow_service": "Allow service",
"allowed": "Allowed",
"allowlist_add": "Add allowlist",
"allowlist_edit": "Edit allowlist",
@@ -137,7 +135,7 @@
"dashboard": "Dashboard",
"dashboard_statistics_disabled": "Statistics retention is disabled in General settings",
"delete_btn": "Delete",
"delete_confirm": "Delete lease?",
"delete_confirm": "Remove lease?",
"delete_confirm_desc": "This will free <b>%ip%</b>",
"delete_table_action": "Delete",
"delete_table_action_confirm": "Yes, delete",
@@ -271,6 +269,7 @@
"dns_override_min_ttl_placeholder": "Time-to-live (TTL)",
"dns_override_min_ttl_title": "Override minimum TTL",
"dns_privacy": "DNS Privacy",
"dns_private_reverse_no_default_resolvers": "AdGuard Home could not determine suitable private reverse DNS resolvers for this system. Specify them to enable this setting.",
"dns_private_reverse_resolve_clients_desc": "Resolves client IP addresses to their hostnames using PTR queries. Uses private DNS servers for local clients and upstream servers for clients with public IP addresses",
"dns_private_reverse_resolve_clients_title": "Reverse lookup of client IP addresses",
"dns_private_reverse_resolvers": "Private reverse DNS resolvers",
@@ -280,9 +279,8 @@
"dns_private_reverse_servers_desc_2": "If this field is empty, your operating system's default DNS resolvers are used, except for AdGuard Home IP addresses. By default, AdGuard Home uses the following reverse DNS resolvers: %value_1% and %value_2%",
"dns_private_reverse_servers_label": "Private reverse DNS servers, one per line",
"dns_private_reverse_servers_placeholder": "IP addresses, sdns://, quic://, https://, h3://, or tls://",
"dns_private_reverse_servers_required": "Specify at least one private reverse DNS server or turn off private reverse DNS resolvers",
"dns_private_reverse_servers_resolvers": "Default DNS resolvers: %value_1% and %value_2%",
"dns_private_reverse_servers_title": "Private reverse DNS servers",
"dns_protocols_title": "DNS protocols",
"dns_queries": "DNS queries",
"dns_queries_tooltip": "The number of DNS queries processed for the last 24 hours",
"dns_query": "DNS queries",
@@ -299,7 +297,6 @@
"dns_rate_limit_title": "Rate limit",
"dns_rate_limit_value": "%value% req/s",
"dns_rewrite_exists": "This DNS rewrite already exists",
"dns_rewrite_removed": "DNS rewrite removed",
"dns_rewrite_same": "You can’t rewrite to the same domain or wildcard",
"dns_rewrites": "DNS rewrites",
"dns_rewrites_desc": "Lists your custom DNS responses for specific domain names",
@@ -372,8 +369,6 @@
"encryption_server_tooltip": "The domain name of your server. It must match a DNS name in the certificate. If set, AdGuard Home will detect ClientIDs, respond to DDR queries, and perform additional connection validations",
"encryption_subject": "Subject: %value%",
"encryption_title": "Encryption",
"encryption_unable_read_cert": "Unable to read certificate file",
"encryption_unable_read_key": "Unable to read private key file",
"error": "Error",
"filter_added_successfully": "Added blocklist: <strong>%value%</strong>",
"filter_added_successfully_allowlist": "Added allowlist: <strong>%value%</strong>",
@@ -404,7 +399,6 @@
"form_error_answer_format": "Invalid result format",
"form_error_client_id_format": "ClientID must contain only numbers, lowercase letters, and hyphens",
"form_error_domain_format": "Invalid domain format",
"form_error_equal": "Must not be equal",
"form_error_format": "Invalid format",
"form_error_format_line": "Invalid format on line %line%",
"form_error_format_lines": "Invalid format on lines %lines%",
@@ -447,15 +441,14 @@
"inactivity_schedule_edit": "Edit schedule",
"inactivity_schedule_no_schedules": "No schedule",
"inactivity_schedule_replace_desc": "This schedule will replace any existing schedules for the same day of the week. Each day can have only one inactivity period",
"inactivity_schedule_time_from": "Time from",
"inactivity_schedule_time_to": "Time to",
"inactivity_schedule_time_from": "Time from (HH:mm)",
"inactivity_schedule_time_to": "Time to (HH:mm)",
"inactivity_schedule_timezone": "Timezone",
"install_auth_confirm": "Confirm password",
"install_auth_password": "Password",
"install_auth_password_enter": "Enter password",
"install_auth_username": "Username",
"install_auth_username_enter": "Enter username",
"install_saved": "Saved successfully",
"install_settings_all_interfaces": "All interfaces",
"install_settings_port": "Port",
"install_static_configure": "AdGuard Home has detected that the dynamic IP address %ip% is used. Do you want it to be set as your static address?",
@@ -519,7 +512,6 @@
"password_requirements_match": "Passwords match",
"password_requirements_special": "English letters, numbers, and special characters",
"password_requirements_uppercase": "At least 1 uppercase letter",
"path_to_file_placeholder": "Path to file: local or Internet address",
"pause_for_hour": "Pause for %count% hour",
"pause_for_minutes": "| Pause for %count% minute | Pause for %count% minutes",
"pause_for_seconds": "| Pause for %count% second | Pause for %count% seconds",
@@ -582,7 +574,7 @@
"remove": "Remove",
"remove_tag": "Remove %value%",
"remove_tls_certificate": "Remove TLS certificate?",
"remove_tls_certificate_desc": "Clients using encrypted DNS won’t be able to connect until a new TLS certificate is added",
"remove_tls_certificate_desc": "Removing the certificate will disable HTTPS and reset TLS settings. If you are connected over HTTPS, your session will end and you will be redirected to http://%host%:%port%",
"report_an_issue": "Report a problem",
"request_table_header": "Request",
"requests_table_header": "Requests",
@@ -621,6 +613,7 @@
"safe_search_used_tooltip": "The number of DNS queries to search engines for which Safe Search was enforced",
"saturday": "Saturday",
"save": "Save",
"save_anyway": "Save anyway",
"save_btn": "Save",
"search_placeholder": "Search",
"select_language": "Select language",
@@ -658,7 +651,7 @@
"settings_notify_query_log_cleared": "Query log cleared",
"settings_notify_statistics_cleared": "Statistics cleared",
"settings_parental_control": "Parental control",
"settings_parental_control_desc": "Block websites with content inappropriate for children",
"settings_parental_control_desc": "Block websites with inappropriate content, such as pornography, violence, gore, and other disturbing material, using AdGuard’s continuously updated family filter database",
"settings_rotation_placeholder": "Use numbers",
"settings_rule_syntax": "Rule syntax",
"settings_safe_search": "Safe search",
@@ -793,7 +786,7 @@
"tls_setup_config_title": "Enable encrypted DNS",
"tls_setup_enable_anyway": "Enable anyway",
"tls_setup_error_cert_incomplete": "Certificate is incomplete. Paste the full text, including the BEGIN and END lines",
"tls_setup_error_duplicate_port": "Port %port% is used by multiple DNS protocols. It must be unique",
"tls_setup_error_duplicate_port": "Port %port% is used by more than one protocol. Choose a different port for each encrypted DNS protocol",
"tls_setup_error_ed25519_key": "This key type is not supported by browsers. Use an RSA or ECDSA key instead",
"tls_setup_error_enable_failed": "Unable to enable encrypted DNS. Check the settings and try again",
"tls_setup_error_key_incomplete": "Private key is incomplete. Paste the full text, including the BEGIN and END lines",
+7 -7
View File
@@ -460,11 +460,11 @@
"ip_address": "IP adresa",
"known_tracker": "Poznati pratitelj",
"language": "Jezik",
"last_days": "| Zadnji %count% dan | Zadnjih %count% dana",
"last_hours": "| Zadnjih %count% sat | Zadnjih %count% sati",
"last_days": "| Zadnji %count% dan | Zadnja %count% dana | Zadnjih %count% dana",
"last_hours": "| Zadnjih %count% sat | Zadnja %count% sata | Zadnjih %count% sati",
"last_updated_label": "Zadnja izmjena",
"light_theme": "Svijetla tema",
"list_updated": "| %count% popis ažuriran | %count% popisi ažurirani",
"list_updated": "| %count% popis ažuriran | %count% popisa ažurirana | %count% popisi ažurirani",
"login": "PRIJAVA",
"logout": "Odjava",
"make_static": "Učini statičnim",
@@ -513,8 +513,8 @@
"password_requirements_uppercase": "Najmanje jedno veliko slovo",
"path_to_file_placeholder": "Put do datoteke: lokalna ili internetska adresa",
"pause_for_hour": "Pauza na %count% sat",
"pause_for_minutes": "| Pauza od %count% minute | Pauza od %count% minuta",
"pause_for_seconds": "| Pauza %count% sekunde | Pauza na %count% sekunde",
"pause_for_minutes": "| Pauza od %count% minute | Pauza od %count% minute | Pauza od %count% minuta",
"pause_for_seconds": "| Pauza %count% sekunde | Pauza na %count% sekunde | Pauza na %count% sekunde",
"pause_until_tomorrow": "Pauza do sutra, %time%",
"period_notify": "Za promjenu prikazanih razdoblja uredite <i>Zadržavanje statistike</i> u <a>Općim postavkama</a>",
"plain_dns": "Obični DNS",
@@ -635,14 +635,14 @@
"settings_confirm_decrease_stats_rotation_interval": "Smanjiti interval zadržavanja statistika?",
"settings_confirm_decrease_stats_rotation_interval_desc": "To će izbrisati sve statistike starije od %value%",
"settings_custom": "Prilagođeno",
"settings_days": "| %count% dan | %count% dana",
"settings_days": "| %count% dan | %count% dana | %count% dana",
"settings_domain_names": "Domene, po jedna po liniji",
"settings_filter_requests": "Zahtjevi za filtriranje",
"settings_filter_requests_desc": "Možete koristiti <a>Blocklists</a>, <b>Allowlists</b> i <c>Korisnička pravila</c> za postavljanje pravila filtriranja",
"settings_filtering_and_security": "Filtriranje i sigurnost",
"settings_general_short": "Općenito",
"settings_global": "Globalno",
"settings_hours": "| %count% sat | %count% sati",
"settings_hours": "| %count% sat | %count% sata | %count% sati",
"settings_log_rotation_hours": "Rotacija trupaca, u satima",
"settings_notify_changes_saved": "Promjene su spremljene",
"settings_notify_query_log_cleared": "Dnevnik upita očišćen",
@@ -115,3 +115,35 @@ describe('Textarea — comment highlight', () => {
expect(overlay?.parentElement).toBe(textarea?.parentElement);
});
});
describe('Textarea — onCard', () => {
it('applies the onCard class to the field when set', () => {
const { container } = render(() => <Textarea value="text" onCard />);
expect(container.querySelector('textarea')?.className).toContain('onCard');
});
it('does not apply the onCard class by default', () => {
const { container } = render(() => <Textarea value="text" />);
expect(container.querySelector('textarea')?.className).not.toContain('onCard');
});
it('applies the onCard class to the scroll container in highlighting mode', () => {
const { container } = render(() => (
<Textarea
value="# comment"
highlightComments
commentPrefixes={COMMENT_LINE_TOKENS}
onCard
/>
));
// The visible surface in highlighting mode is the scroll container,
// not the transparent textarea layered on top of it.
const textarea = container.querySelector('textarea');
const scrollArea = textarea?.closest('[class*="scrollArea"]');
expect(scrollArea?.className).toContain('onCard');
expect(textarea?.className).not.toContain('onCard');
});
});
@@ -69,6 +69,9 @@ const resetStores = () => {
describe('Banners', () => {
beforeEach(() => {
resetStores();
// The Update buttons navigate; start every test from the root route so
// a previous navigation cannot hide the banner.
window.location.hash = '';
});
// ── Priority logic cases ──
@@ -210,6 +213,32 @@ describe('Banners', () => {
expect(screen.queryByTestId('banner-tls-expired')).not.toBeInTheDocument();
});
// ── Update button deep-links into the TLS setup wizard ──
it('opens the TLS setup wizard from the expired banner', async () => {
const user = userEvent.setup();
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString(); // expired
renderBanners();
await user.click(screen.getByRole('button', { name: copyInDom('update_button') }));
expect(window.location.hash).toBe('#/encryption?tlsWizard=true');
});
it('opens the TLS setup wizard from the expiring banner', async () => {
const user = userEvent.setup();
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = new Date(Date.now() + 15 * 86400000).toISOString(); // 15 days
renderBanners();
await user.click(screen.getByRole('button', { name: copyInDom('update_button') }));
expect(window.location.hash).toBe('#/encryption?tlsWizard=true');
});
// ── forceBanner (dev test override) ──
it('renders forced TLS expired banner regardless of store state', () => {
@@ -0,0 +1,44 @@
import { render, screen } from '@solidjs/testing-library';
import { describe, it, expect, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { ClientBlockMenuItem } from 'panel/common/ui/ClientBlockConfirm';
import { copyInDom } from 'panel/__tests__/helpers/copy';
describe('ClientBlockMenuItem', () => {
it('renders the block action as a button', async () => {
const onClick = vi.fn();
render(() => <ClientBlockMenuItem action="block" onClick={onClick} />);
const button = screen.getByRole('button', { name: copyInDom('block_client') });
expect(button.tagName).toBe('BUTTON');
expect(button.getAttribute('type')).toBe('button');
await userEvent.click(button);
expect(onClick).toHaveBeenCalledWith('block');
});
it('renders the unblock action as a button', async () => {
const onClick = vi.fn();
render(() => <ClientBlockMenuItem action="unblock" onClick={onClick} />);
const button = screen.getByRole('button', { name: copyInDom('unblock_client') });
expect(button.tagName).toBe('BUTTON');
expect(button.getAttribute('type')).toBe('button');
await userEvent.click(button);
expect(onClick).toHaveBeenCalledWith('unblock');
});
it('exposes the action through a test id', () => {
render(() => <ClientBlockMenuItem action="block" onClick={() => {}} />);
expect(screen.getByTestId('client-block-menu-item')).toBeInTheDocument();
});
it('merges the caller class onto the button', () => {
render(() => (
<ClientBlockMenuItem action="unblock" onClick={() => {}} class="custom-class" />
));
expect(screen.getByRole('button').className).toContain('custom-class');
});
});
@@ -116,4 +116,31 @@ describe('ConfigDialog', () => {
const fieldset = screen.getByTestId('test-input').closest('fieldset');
expect(fieldset).toBeDisabled();
});
it('keeps the primary submit style by default', () => {
render(() => (
<ConfigDialog open={true} title="Test" onClose={vi.fn()} onSubmit={vi.fn()}>
<div>Content</div>
</ConfigDialog>
));
expect(screen.getByTestId('config-dialog-save').className).toContain('primary');
});
it('styles the submit button as a warning when asked', () => {
render(() => (
<ConfigDialog
open={true}
title="Test"
onClose={vi.fn()}
onSubmit={vi.fn()}
buttonText="save_anyway"
buttonVariant="warning"
>
<div>Content</div>
</ConfigDialog>
));
const saveButton = screen.getByTestId('config-dialog-save');
expect(saveButton.className).toContain('warning');
expect(saveButton).toHaveTextContent('save_anyway');
});
});
@@ -0,0 +1,75 @@
import { render, screen, fireEvent } from '@solidjs/testing-library';
import { MemoryRouter, Route, createMemoryHistory } from '@solidjs/router';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Link } from 'panel/common/ui/Link';
import { RoutePath } from 'panel/components/Routes/Paths';
import type { QueryParams } from 'panel/components/Routes/Paths';
/**
* A memory router keeps each case on its own history: following a link in one
* test would otherwise leave the next one on a route it does not declare.
*/
const renderRow = (children?: unknown, query?: QueryParams) => {
const history = createMemoryHistory();
history.set({ value: '/' });
return render(() => (
<MemoryRouter history={history}>
<Route
path="/"
component={() => (
<Link to={RoutePath.QueryLog} query={query} data-testid="row">
{(children ?? 'row content') as never}
</Link>
)}
/>
</MemoryRouter>
));
};
describe('Link scroll-to-top', () => {
let scrollTo: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
});
afterEach(() => {
scrollTo.mockRestore();
vi.useRealTimers();
});
it('scrolls to the top after following the link', () => {
renderRow();
fireEvent.click(screen.getByTestId('row'));
vi.advanceTimersByTime(150);
expect(scrollTo).toHaveBeenCalledWith({ top: 0 });
});
it('does not scroll when a nested control cancels the navigation', () => {
renderRow(
<button type="button" onClick={(e) => e.preventDefault()}>
Row action
</button>,
);
fireEvent.click(screen.getByRole('button'));
vi.advanceTimersByTime(150);
expect(scrollTo).not.toHaveBeenCalled();
});
it('does not scroll when the target section handles it', () => {
renderRow(undefined, { section: 'settings' });
fireEvent.click(screen.getByTestId('row'));
vi.advanceTimersByTime(150);
expect(scrollTo).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,53 @@
import { render, screen } from '@solidjs/testing-library';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TruncatedText } from 'panel/common/ui/TruncatedText';
import { mockMatchMedia } from 'panel/__tests__/helpers/matchMedia';
/** jsdom reports every element as 0×0, so the measurement is stubbed. */
const truncated = vi.hoisted(() => ({ value: false }));
vi.mock('panel/hooks/useIsTruncated', () => ({
useIsTruncated: () => () => truncated.value,
}));
const LONG_TEXT = 'very-long-subdomain.tracking-vendor.example.com';
describe('TruncatedText', () => {
beforeEach(() => {
truncated.value = false;
mockMatchMedia(false);
});
it('renders the text without a native title', () => {
const { container } = render(() => <TruncatedText text={LONG_TEXT} testId="cell" />);
expect(screen.getByTestId('cell')).toHaveTextContent(LONG_TEXT);
expect(container.querySelector('[title]')).toBeNull();
});
it('renders no tooltip while the text fits', () => {
const { container } = render(() => <TruncatedText text={LONG_TEXT} />);
expect(container.querySelector('[data-scope="tooltip"]')).toBeNull();
});
it('reveals the full text in a tooltip once it is clipped', () => {
truncated.value = true;
const { container } = render(() => <TruncatedText text={LONG_TEXT} />);
expect(container.querySelector('[data-part="content"]')).toHaveTextContent(LONG_TEXT);
expect(container.querySelector('[title]')).toBeNull();
});
it('renders no tooltip on touch devices', () => {
truncated.value = true;
mockMatchMedia((query) => query === '(hover: none)');
const { container } = render(() => <TruncatedText text={LONG_TEXT} />);
expect(screen.getByText(LONG_TEXT)).toBeInTheDocument();
expect(container.querySelector('[data-scope="tooltip"]')).toBeNull();
});
});
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@solidjs/testing-library';
import {
ServiceIcons,
type WebService,
} from 'panel/components/Clients/blocks/PersistentClientsTable/ServiceIcons';
const SVG = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="4" /></svg>';
const makeServiceMap = (ids: string[]) =>
new Map<string, WebService>(
ids.map((id) => [
id,
{ id, name: id, icon_svg: SVG, group_id: 'other', rules: [] as string[] },
]),
);
const renderStrip = (serviceIds: string[], maxVisible?: number) =>
render(() => (
<ServiceIcons
serviceIds={serviceIds}
serviceMap={makeServiceMap(serviceIds)}
maxVisible={maxVisible}
/>
));
const renderedIcons = () => screen.queryAllByTestId('service-icon');
const badgeCount = () => screen.queryByTestId('services-count')?.textContent ?? null;
const manyServices = (count: number) => Array.from({ length: count }, (_, i) => `service-${i}`);
describe('ServiceIcons', () => {
it('renders two inline icons and counts the remaining ones', () => {
renderStrip(manyServices(12));
expect(renderedIcons()).toHaveLength(2);
expect(badgeCount()).toBe('10');
});
it('keeps the icon count and the badge in sync with the total', () => {
// The invariant that broke the layout: the strip must never render more
// icons than it accounts for, otherwise the extra icon overflows the cell.
renderStrip(manyServices(12));
const visible = renderedIcons().length;
expect(visible + Number(badgeCount())).toBe(12);
});
it('collapses the third service into the count chip', () => {
// Regression guard: three inline icons need 102px but the narrowest
// `blocked_services` cell only provides 96px, so the last icon used to
// be clipped mid-glyph.
renderStrip(manyServices(3));
expect(renderedIcons()).toHaveLength(2);
expect(badgeCount()).toBe('1');
});
it('renders no count chip when every service fits', () => {
renderStrip(manyServices(2));
expect(renderedIcons()).toHaveLength(2);
expect(badgeCount()).toBeNull();
});
it('renders a single icon without a count chip', () => {
renderStrip(manyServices(1));
expect(renderedIcons()).toHaveLength(1);
expect(badgeCount()).toBeNull();
});
it('renders no icons when there are no blocked services', () => {
renderStrip([]);
expect(renderedIcons()).toHaveLength(0);
expect(badgeCount()).toBeNull();
});
it('does not render icons for ids missing from the service map', () => {
render(() => (
<ServiceIcons
serviceIds={['known', 'unknown']}
serviceMap={makeServiceMap(['known'])}
/>
));
expect(renderedIcons()).toHaveLength(1);
expect(badgeCount()).toBeNull();
});
it('respects an explicit maxVisible override', () => {
renderStrip(manyServices(12), 1);
expect(renderedIcons()).toHaveLength(1);
expect(badgeCount()).toBe('11');
});
it('does not truncate the icons when the override exceeds the default', () => {
renderStrip(manyServices(12), 5);
expect(renderedIcons()).toHaveLength(5);
expect(badgeCount()).toBe('7');
});
});
@@ -0,0 +1,254 @@
import { For } from 'solid-js';
import { render, screen, fireEvent } from '@solidjs/testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DAY } from 'panel/helpers/constants';
import { LocalStorageHelper, LOCAL_STORAGE_KEYS } from 'panel/helpers/localStorageHelper';
type StatsStateMock = {
processingGetConfig: boolean;
processingStats: boolean;
configLoaded: boolean;
configAttempted: boolean;
statsAttempted: boolean;
interval: number;
enabled: boolean;
numDnsQueries: number;
dnsQueries: number[];
topClients: { name: string; count: number }[];
topQueriedDomains: { name: string; count: number }[];
};
type HeaderPropsMock = {
selectedPeriod: number;
periodOptions: { value: number; label: string }[];
onPeriodChange: (period: number) => void;
};
const mocks = vi.hoisted(() => ({
getStats: vi.fn(),
getStatsConfig: vi.fn(),
statsState: undefined as StatsStateMock | undefined,
}));
vi.mock('panel/stores/stats', async () => {
const { createMutable } = await import('solid-js/store');
// A mutable store (not a plain object) so the Dashboard memos react when
// the config "arrives", exactly like the real store does.
const statsState = createMutable<StatsStateMock>({
processingGetConfig: false,
processingStats: true,
configLoaded: false,
configAttempted: false,
statsAttempted: false,
interval: DAY,
enabled: true,
numDnsQueries: 0,
dnsQueries: [],
topClients: [],
topQueriedDomains: [],
});
mocks.statsState = statsState;
return {
statsState,
getStats: mocks.getStats,
getStatsConfig: mocks.getStatsConfig,
enableStatistics: vi.fn(),
};
});
vi.mock('panel/stores/dashboard', () => ({
dashboardState: {
protectionEnabled: true,
processingProtection: false,
protectionDisabledDuration: null,
},
toggleProtection: vi.fn(),
getClients: vi.fn(),
}));
vi.mock('panel/stores/access', () => ({
accessState: { processing: false },
getAccessList: vi.fn(),
}));
// The Header is what puts the period into the dropdown, so the stub renders the
// props the Dashboard passes instead of the real Select.
vi.mock('panel/components/Dashboard/blocks/Header/Header', () => ({
Header: (props: HeaderPropsMock) => (
<div>
<span data-testid="selected-period">{props.selectedPeriod}</span>
<For each={props.periodOptions}>
{(option) => (
<button
type="button"
data-testid="period-option"
data-period={option.value}
onClick={() => props.onPeriodChange(option.value)}
>
{option.label}
</button>
)}
</For>
</div>
),
getPeriodLabel: (interval: number) => `period:${interval}`,
}));
vi.mock('panel/components/Dashboard/blocks/StatCards', () => ({ StatCards: (): null => null }));
vi.mock('panel/components/Dashboard/blocks/EmptyState/EmptyState', () => ({
EmptyState: (): null => null,
}));
vi.mock('panel/components/Dashboard/blocks/GeneralStatistics', () => ({
GeneralStatistics: (): null => null,
}));
vi.mock('panel/components/Dashboard/blocks/TopClients', () => ({ TopClients: (): null => null }));
vi.mock('panel/components/Dashboard/blocks/TopQueriedDomains', () => ({
TopQueriedDomains: (): null => null,
}));
vi.mock('panel/components/Dashboard/blocks/TopBlockedDomains', () => ({
TopBlockedDomains: (): null => null,
}));
vi.mock('panel/components/Dashboard/blocks/TopUpstreams', () => ({
TopUpstreams: (): null => null,
}));
vi.mock('panel/components/Dashboard/blocks/UpstreamAvgTime', () => ({
UpstreamAvgTime: (): null => null,
}));
import { Dashboard } from 'panel/components/Dashboard/Dashboard';
const renderDashboard = () => render(() => <Dashboard />);
const selectedPeriodInHeader = () => Number(screen.getByTestId('selected-period').textContent);
const periodOptionValues = () =>
screen
.queryAllByTestId('period-option')
.map((option) => Number(option.getAttribute('data-period')));
const clickPeriodOption = (period: number) => {
const option = screen
.queryAllByTestId('period-option')
.find((candidate) => candidate.getAttribute('data-period') === String(period));
expect(option).toBeDefined();
fireEvent.click(option as HTMLElement);
};
/** Resolves the config request the way a successful `getStatsConfig` does. */
const resolveConfigWith = (interval: number) => {
mocks.getStatsConfig.mockImplementation(async () => {
const statsState = mocks.statsState as StatsStateMock;
statsState.interval = interval;
statsState.configLoaded = true;
statsState.configAttempted = true;
});
};
/** Resolves the config request the way a failed `getStatsConfig` does. */
const failConfig = () => {
mocks.getStatsConfig.mockImplementation(async () => {
(mocks.statsState as StatsStateMock).configAttempted = true;
});
};
describe('Dashboard stats period', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
const statsState = mocks.statsState as StatsStateMock;
statsState.processingGetConfig = false;
statsState.processingStats = true;
statsState.configLoaded = false;
statsState.configAttempted = false;
statsState.statsAttempted = false;
statsState.interval = DAY;
});
it('fetches the stored period once, after the stats config resolves', async () => {
LocalStorageHelper.setItem(LOCAL_STORAGE_KEYS.STATS_PERIOD, DAY * 30);
resolveConfigWith(DAY * 30);
renderDashboard();
await vi.waitFor(() => expect(selectedPeriodInHeader()).toBe(DAY * 30));
// The default DAY period must never be requested: that request is what
// painted 24h data before the selected period.
expect(mocks.getStats).toHaveBeenCalledTimes(1);
expect(mocks.getStats).toHaveBeenCalledWith(DAY * 30);
expect(mocks.getStatsConfig).toHaveBeenCalledTimes(1);
});
it('shows the stored period while the stats config is still loading', () => {
LocalStorageHelper.setItem(LOCAL_STORAGE_KEYS.STATS_PERIOD, DAY * 30);
mocks.getStatsConfig.mockReturnValue(new Promise(() => {}));
renderDashboard();
expect(selectedPeriodInHeader()).toBe(DAY * 30);
expect(periodOptionValues()).toContain(DAY * 30);
expect(mocks.getStats).not.toHaveBeenCalled();
});
it('falls back to the default period when the stats config cannot be loaded', async () => {
LocalStorageHelper.setItem(LOCAL_STORAGE_KEYS.STATS_PERIOD, DAY * 30);
// A failed config request marks the attempt so the dashboard stops
// waiting and fetches; the store clamps the period by the default DAY.
failConfig();
renderDashboard();
await vi.waitFor(() => expect(mocks.getStats).toHaveBeenCalledWith(DAY * 30));
expect(selectedPeriodInHeader()).toBe(DAY);
expect(periodOptionValues()).not.toContain(DAY * 30);
});
it('fetches a newly selected period without re-reading the stats config', async () => {
LocalStorageHelper.setItem(LOCAL_STORAGE_KEYS.STATS_PERIOD, DAY * 30);
resolveConfigWith(DAY * 30);
renderDashboard();
await vi.waitFor(() => expect(mocks.getStats).toHaveBeenCalledWith(DAY * 30));
clickPeriodOption(DAY * 7);
await vi.waitFor(() => expect(mocks.getStats).toHaveBeenCalledWith(DAY * 7));
expect(mocks.getStatsConfig).toHaveBeenCalledTimes(1);
});
it('never requests stats before the first config attempt settles', async () => {
LocalStorageHelper.setItem(LOCAL_STORAGE_KEYS.STATS_PERIOD, DAY * 30);
let resolveConfig!: () => void;
mocks.getStatsConfig.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveConfig = () => {
const statsState = mocks.statsState as StatsStateMock;
statsState.interval = DAY * 30;
statsState.configLoaded = true;
statsState.configAttempted = true;
resolve();
};
}),
);
renderDashboard();
// Config still in flight: no stats request at all, especially not one
// for the default DAY that would paint 24h values first.
expect(mocks.getStats).not.toHaveBeenCalled();
resolveConfig();
await vi.waitFor(() => expect(mocks.getStats).toHaveBeenCalledTimes(1));
expect(mocks.getStats).toHaveBeenCalledWith(DAY * 30);
expect(mocks.getStats).not.toHaveBeenCalledWith(DAY);
});
});
@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@solidjs/testing-library';
import userEvent from '@testing-library/user-event';
// Deep proxy: `theme` is written as theme.layout.container, theme.title.h4, etc.
// CSS modules resolve to empty objects under `css: false`, so either mock the
// exact class names or return the proxy itself for every property.
const { themeMock, encryptionState, mocks } = vi.hoisted(() => {
const proxy: any = new Proxy(
{},
{
get: (_target, prop) => {
if (prop === Symbol.toPrimitive || prop === 'toString') {
return () => '';
}
return proxy;
},
},
);
return {
themeMock: proxy,
encryptionState: {
enabled: false,
processingConfig: false,
processingValidate: false,
certificate_chain: '',
certificate_path: '',
private_key: '',
private_key_path: '',
private_key_saved: false,
server_name: '',
serve_plain_dns: true,
force_https: false,
port_https: 443,
port_dns_over_tls: 853,
port_dns_over_quic: 853,
port_dnscrypt: 0,
} as Record<string, unknown>,
mocks: {
getTlsStatus: vi.fn(),
setTlsConfig: vi.fn(),
validateTlsConfig: vi.fn(),
},
};
});
vi.mock('panel/lib/theme', () => ({
default: themeMock,
}));
vi.mock('panel/stores/encryption', () => ({
encryptionState,
...mocks,
}));
vi.mock('panel/stores/dashboard', () => ({
dashboardState: { httpPort: 80, dnsPort: 53 },
}));
// The page reads the `tlsWizard` query param to auto-open the wizard; no
// params here, and `setSearchParams` must be a no-op.
vi.mock('@solidjs/router', () => ({
useSearchParams: () => [{}, vi.fn()],
}));
vi.mock('panel/common/intl', async () =>
(await import('panel/__tests__/helpers/copy')).createIntlMock(),
);
// The child blocks have their own tests — stub them so this test only covers
// the page's switch logic. Each stub reports its `open` state through
// `data-open`, which is what the assertions read.
vi.mock('panel/components/Encryption/blocks/PlainDnsToggle', () => ({
PlainDnsToggle: () => <div data-testid="plain-dns-toggle" />,
}));
vi.mock('panel/components/Encryption/blocks/TlsCertSection', () => ({
TlsCertSection: () => <div data-testid="tls-cert-section" />,
}));
vi.mock('panel/components/Encryption/blocks/ServerSettingsRow', () => ({
ServerSettingsRow: () => <div data-testid="server-settings-row" />,
}));
vi.mock('panel/components/Encryption/blocks/RedirectToggle', () => ({
RedirectToggle: () => <div data-testid="redirect-toggle" />,
}));
vi.mock('panel/components/Encryption/blocks/ResetDnsModal', () => ({
ResetDnsModal: (props: { open: boolean }) => (
<div data-testid="reset-dns-modal" data-open={String(props.open)} />
),
}));
vi.mock('panel/components/Encryption/blocks/ServerSettingsModal', () => ({
ServerSettingsModal: (props: { open: boolean }) => (
<div data-testid="server-settings-modal" data-open={String(props.open)} />
),
}));
vi.mock('panel/components/Encryption/blocks/SetupWizard', () => ({
TlsSetupWizard: (props: { open: boolean }) => (
<div data-testid="tls-wizard" data-open={String(props.open)} />
),
}));
import { Encryption } from 'panel/components/Encryption/Encryption';
const renderPage = async () => {
render(() => <Encryption />);
// The switch only renders after `getTlsStatus` resolves.
const switchInput = await waitFor(() => screen.getByRole('checkbox'));
return switchInput;
};
const openState = (testId: string) => screen.getByTestId(testId).getAttribute('data-open');
beforeEach(() => {
vi.clearAllMocks();
Object.assign(encryptionState, {
enabled: false,
processingConfig: false,
certificate_chain: '',
certificate_path: '',
private_key: '',
private_key_path: '',
private_key_saved: false,
server_name: '',
});
mocks.getTlsStatus.mockResolvedValue(undefined);
mocks.setTlsConfig.mockResolvedValue({ ok: true });
});
describe('Encryption page — encrypted DNS switch', () => {
it('enables encryption when a certificate and key are set, even without a server name', async () => {
encryptionState.certificate_path = '/etc/certs/fullchain.pem';
encryptionState.private_key_path = '/etc/certs/privkey.pem';
encryptionState.server_name = '';
const switchInput = await renderPage();
await userEvent.click(switchInput);
expect(mocks.setTlsConfig).toHaveBeenCalledWith({ enabled: true });
expect(openState('server-settings-modal')).toBe('false');
expect(openState('tls-wizard')).toBe('false');
});
it('enables encryption when a server name is set as well', async () => {
encryptionState.certificate_path = '/etc/certs/fullchain.pem';
encryptionState.private_key_path = '/etc/certs/privkey.pem';
encryptionState.server_name = 'dns.example.com';
const switchInput = await renderPage();
await userEvent.click(switchInput);
expect(mocks.setTlsConfig).toHaveBeenCalledWith({ enabled: true });
expect(openState('server-settings-modal')).toBe('false');
expect(openState('tls-wizard')).toBe('false');
});
it('opens the TLS wizard without saving when the key is missing', async () => {
encryptionState.certificate_path = '/etc/certs/fullchain.pem';
encryptionState.private_key_path = '';
const switchInput = await renderPage();
await userEvent.click(switchInput);
expect(mocks.setTlsConfig).not.toHaveBeenCalled();
expect(openState('tls-wizard')).toBe('true');
expect(openState('server-settings-modal')).toBe('false');
expect(screen.getByRole('checkbox')).not.toBeChecked();
});
});
@@ -4,12 +4,30 @@ import userEvent from '@testing-library/user-event';
const mocks = vi.hoisted(() => ({
setTlsConfig: vi.fn(),
validateTlsConfig: vi.fn(),
tlsStatus: vi.fn(),
tlsConfigure: vi.fn(),
tlsValidate: vi.fn(),
addErrorToast: vi.fn(),
addSuccessToast: vi.fn(),
redirectToCurrentProtocol: vi.fn(),
// The modal reads the TLS config from the store on open and validates the
// edited name against the saved certificate; keep the store out of these
// tests and drive both through this object.
encryptionState: {
server_name: 'dns.example.com',
port_https: 443,
port_dns_over_tls: 853,
port_dns_over_quic: 853,
port_dnscrypt: 0,
certificate_chain: 'CERTIFICATE',
certificate_path: '',
private_key: 'PRIVATE KEY',
private_key_path: '',
private_key_saved: false,
processingConfig: false,
processingValidate: false,
},
}));
vi.mock('panel/api/generated', () => ({
@@ -30,19 +48,12 @@ vi.mock('panel/stores/dashboard', () => ({
vi.mock('panel/helpers/helpers', () => ({
redirectToCurrentProtocol: mocks.redirectToCurrentProtocol,
}));
// The modal reads the TLS config from the store on open; keep the store out of
// these tests and drive the values through the saved configuration.
// The store mock keeps `validateTlsConfig` local: these tests pin what the
// modal sends and how it renders the verdict, not the store's HTTP plumbing.
vi.mock('panel/stores/encryption', () => ({
encryptionState: {
server_name: 'dns.example.com',
port_https: 443,
port_dns_over_tls: 853,
port_dns_over_quic: 853,
port_dnscrypt: 0,
processingConfig: false,
processingValidate: false,
},
encryptionState: mocks.encryptionState,
setTlsConfig: mocks.setTlsConfig,
validateTlsConfig: mocks.validateTlsConfig,
}));
import { ServerSettingsModal } from 'panel/components/Encryption/blocks/ServerSettingsModal';
@@ -63,9 +74,19 @@ const renderModal = () => {
const saveButton = () => screen.getByTestId('config-dialog-save');
const conflictMessage = copy('tls_setup_error_port_in_use');
/** What the backend reports when the certificate does not cover the name. */
const MISMATCH =
'validating certificate pair: certificate does not verify: x509: certificate is valid for dns.example.com, not localhost';
/** A certificate verdict with no complaint — the saved pair is fine. */
const CERT_OK = { valid_cert: true, valid_key: true, valid_pair: true };
beforeEach(() => {
vi.clearAllMocks();
mocks.setTlsConfig.mockResolvedValue({ ok: true });
mocks.validateTlsConfig.mockResolvedValue(CERT_OK);
mocks.encryptionState.certificate_chain = 'CERTIFICATE';
mocks.encryptionState.private_key = 'PRIVATE KEY';
});
describe('ServerSettingsModal — opening', () => {
@@ -170,11 +191,14 @@ describe('ServerSettingsModal — saving', () => {
await user.click(saveButton());
expect(mocks.setTlsConfig).toHaveBeenCalledWith({
server_name: 'dns.example.com',
port_https: 443,
port_dns_over_tls: 853,
port_dns_over_quic: 853,
// The save runs after the certificate check, so it settles a tick later.
await waitFor(() => {
expect(mocks.setTlsConfig).toHaveBeenCalledWith({
server_name: 'dns.example.com',
port_https: 443,
port_dns_over_tls: 853,
port_dns_over_quic: 853,
});
});
expect(onClose).toHaveBeenCalled();
});
@@ -188,11 +212,13 @@ describe('ServerSettingsModal — saving', () => {
await user.type(https, '8443');
await user.click(saveButton());
expect(mocks.setTlsConfig).toHaveBeenCalledWith({
server_name: 'dns.example.com',
port_https: 8443,
port_dns_over_tls: 853,
port_dns_over_quic: 853,
await waitFor(() => {
expect(mocks.setTlsConfig).toHaveBeenCalledWith({
server_name: 'dns.example.com',
port_https: 8443,
port_dns_over_tls: 853,
port_dns_over_quic: 853,
});
});
expect(onClose).toHaveBeenCalled();
expect(mocks.addErrorToast).not.toHaveBeenCalled();
@@ -214,15 +240,135 @@ describe('ServerSettingsModal — saving', () => {
expect(screen.getAllByDisplayValue('853')).toHaveLength(2);
await user.click(saveButton());
expect(mocks.setTlsConfig).toHaveBeenCalledWith({
server_name: '',
port_https: 443,
port_dns_over_tls: 853,
port_dns_over_quic: 853,
await waitFor(() => {
expect(mocks.setTlsConfig).toHaveBeenCalledWith({
server_name: '',
port_https: 443,
port_dns_over_tls: 853,
port_dns_over_quic: 853,
});
});
});
});
describe('ServerSettingsModal — the certificate warning', () => {
const warningText = () =>
copyInDom('tls_setup_warning_server_name_mismatch', { hostname: 'localhost' });
it('warns under the server name when the saved certificate does not cover it', async () => {
const user = userEvent.setup();
mocks.validateTlsConfig.mockResolvedValue({ ...CERT_OK, warning_validation: MISMATCH });
renderModal();
const serverName = screen.getByDisplayValue('dns.example.com');
await user.clear(serverName);
await user.type(serverName, 'localhost');
await user.tab();
await waitFor(() => {
expect(screen.getByTestId('server-settings-server-name-warning')).toHaveTextContent(
warningText(),
);
});
// The check asks about the edited name against the saved certificate
// and key — the wizard's config-step payload.
expect(mocks.validateTlsConfig).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
server_name: 'localhost',
certificate_chain: 'CERTIFICATE',
private_key: 'PRIVATE KEY',
}),
{ persist: false },
);
});
it('reveals a newly discovered warning on the first Save click and saves on the next', async () => {
const user = userEvent.setup();
const onClose = renderModal();
// Clicking Save blurs the field first, so the blur check is still in
// flight when the click handler runs — the real-world timing. The
// click's own check is the one that decides.
let releaseBlurCheck: (value: unknown) => void = () => {};
mocks.validateTlsConfig.mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseBlurCheck = resolve;
}),
);
mocks.validateTlsConfig.mockResolvedValue({ ...CERT_OK, warning_validation: MISMATCH });
const serverName = screen.getByDisplayValue('dns.example.com');
await user.clear(serverName);
await user.type(serverName, 'localhost');
await user.click(saveButton());
await waitFor(() => {
expect(screen.getByTestId('server-settings-server-name-warning')).toHaveTextContent(
warningText(),
);
});
expect(mocks.setTlsConfig).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(saveButton()).toHaveTextContent(copyInDom('save_anyway'));
releaseBlurCheck(CERT_OK);
// The warning is on screen now, so this click is the confirmation.
await user.click(saveButton());
await waitFor(() => {
expect(mocks.setTlsConfig).toHaveBeenCalledWith({
server_name: 'localhost',
port_https: 443,
port_dns_over_tls: 853,
port_dns_over_quic: 853,
});
});
expect(onClose).toHaveBeenCalled();
});
it('hides the warning as soon as the name is edited', async () => {
const user = userEvent.setup();
mocks.validateTlsConfig.mockResolvedValue({ ...CERT_OK, warning_validation: MISMATCH });
renderModal();
const serverName = screen.getByDisplayValue('dns.example.com');
await user.clear(serverName);
await user.type(serverName, 'localhost');
await user.tab();
await waitFor(() => {
expect(screen.getByTestId('server-settings-server-name-warning')).toBeInTheDocument();
});
// The warning names the checked host, so it must not outlive the
// value it was reported for.
await user.type(serverName, 'x');
expect(screen.queryByTestId('server-settings-server-name-warning')).toBeNull();
expect(saveButton()).toHaveTextContent(copyInDom('save'));
});
it('does not ask the backend when no certificate is configured', async () => {
const user = userEvent.setup();
mocks.encryptionState.certificate_chain = '';
renderModal();
const serverName = screen.getByDisplayValue('dns.example.com');
await user.clear(serverName);
await user.type(serverName, 'localhost');
await user.tab();
// Without a certificate there is nothing to verify the name against.
expect(mocks.validateTlsConfig).not.toHaveBeenCalled();
expect(screen.queryByTestId('server-settings-server-name-warning')).toBeNull();
});
});
describe('ServerSettingsFields — the settings shared with the wizard', () => {
const values = { server_name: 'example.com', port_https: 443 };
@@ -273,6 +419,25 @@ describe('ServerSettingsFields — the settings shared with the wizard', () => {
expect(onFieldBlur).toHaveBeenCalledWith('port_https');
});
it('reports the live server name on every keystroke when the host asks for it', async () => {
const user = userEvent.setup();
const onFieldInput = vi.fn();
render(() => (
<ServerSettingsFields
values={values}
onFieldChange={vi.fn()}
onFieldBlur={vi.fn()}
onFieldInput={onFieldInput}
/>
));
// `onFieldChange` only fires on `change` (blur), so the host needs the
// input stream to keep a warning about the typed name up to date.
await user.type(screen.getByDisplayValue('example.com'), 'x');
expect(onFieldInput).toHaveBeenLastCalledWith('server_name', 'example.comx');
});
it('fills the default ports in the tooltips from the shared constants', () => {
// The tooltips are rendered with their text nodes even when closed.
render(() => (
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
// Deep proxy: `theme` is written as theme.form.action, theme.dialog.body, etc.
// CSS modules resolve to empty objects under `css: false`, so either mock the
// exact class names or return the proxy itself for every property.
const { themeMock, encryptionState, mocks } = vi.hoisted(() => {
const { themeMock, encryptionState, mocks, dashboardState } = vi.hoisted(() => {
const proxy: any = new Proxy(
{},
{
@@ -43,6 +43,8 @@ const { themeMock, encryptionState, mocks } = vi.hoisted(() => {
resetValidationStatus: vi.fn(),
applyTlsOptimistically: vi.fn(),
},
// Mutable so a test can drive the Web UI port the removal dialog names.
dashboardState: { httpPort: 8080 } as { httpPort: number | undefined },
};
});
@@ -55,6 +57,13 @@ vi.mock('panel/stores/encryption', () => ({
...mocks,
}));
// The removal dialog names the HTTP address the session is redirected to: the
// current host at the Web UI port. A non-default port keeps the assertion
// from passing on a hard-coded standard port.
vi.mock('panel/stores/dashboard', () => ({
dashboardState,
}));
// The section renders localized copy; serve it from the base locale so the
// assertions can name the key instead of re-stating the text.
vi.mock('panel/common/intl', async () =>
@@ -63,6 +72,7 @@ vi.mock('panel/common/intl', async () =>
import { TlsCertSection } from 'panel/components/Encryption/blocks/TlsCertSection';
import { copy, copyInDom } from 'panel/__tests__/helpers/copy';
import { STANDARD_WEB_PORT } from 'panel/helpers/constants';
const baseState = {
enabled: true,
@@ -93,6 +103,7 @@ const algorithmLine = () => screen.queryByText(/Encryption algorithm/);
beforeEach(() => {
vi.clearAllMocks();
dashboardState.httpPort = 8080;
});
describe('TlsCertSection — encryption algorithm', () => {
@@ -233,6 +244,40 @@ describe('TlsCertSection — remove certificate', () => {
expect(mocks.setTlsConfig).not.toHaveBeenCalled();
});
it('names the HTTP address the redirect lands on', async () => {
const user = userEvent.setup();
renderSection();
await openConfirm(user);
expect(
screen.getByText(
copyInDom('remove_tls_certificate_desc', {
host: window.location.hostname,
port: 8080,
}),
),
).toBeInTheDocument();
});
it('falls back to the standard port when the status has not reported one', async () => {
dashboardState.httpPort = 0;
const user = userEvent.setup();
renderSection();
await openConfirm(user);
// Without the fallback the copy would read "http://host:0".
expect(
screen.getByText(
copyInDom('remove_tls_certificate_desc', {
host: window.location.hostname,
port: STANDARD_WEB_PORT,
}),
),
).toBeInTheDocument();
});
it('turns encryption off and resets the TLS data', async () => {
const user = userEvent.setup();
renderSection();
@@ -1,7 +1,7 @@
import { type JSX } from 'solid-js';
import { render, screen } from '@solidjs/testing-library';
import { render, screen, within } from '@solidjs/testing-library';
import { HashRouter, Route } from '@solidjs/router';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { TopClients } from 'panel/components/Dashboard/blocks/TopClients';
import { TopQueriedDomains } from 'panel/components/Dashboard/blocks/TopQueriedDomains';
@@ -9,7 +9,9 @@ import { TopBlockedDomains } from 'panel/components/Dashboard/blocks/TopBlockedD
import { TopUpstreams } from 'panel/components/Dashboard/blocks/TopUpstreams';
import { UpstreamAvgTime } from 'panel/components/Dashboard/blocks/UpstreamAvgTime';
import { DAY } from 'panel/helpers/constants';
import { formatCompactNumber } from 'panel/helpers/helpers';
import { copyInDom } from 'panel/__tests__/helpers/copy';
import { mockMatchMedia } from 'panel/__tests__/helpers/matchMedia';
const renderWithRouter = (ui: () => JSX.Element) =>
render(() => (
@@ -20,10 +22,40 @@ const renderWithRouter = (ui: () => JSX.Element) =>
const getLinkHref = (testid: string) => screen.getByTestId(testid).getAttribute('href') ?? '';
/**
* The row that shows `domain`. The row's tooltip repeats the domain name, so
* the lookup has to stay scoped to the row itself.
*/
const getDomainRow = (domain: string) => {
const row = screen
.getAllByTestId('top-domain-row')
.find((candidate) => within(candidate).queryByText(domain) !== null);
expect(row).toBeDefined();
return row as HTMLElement;
};
/** Rows a card shows before the "Show more" footer would reveal anything. */
const DOMAINS_VISIBLE_ITEMS = 5;
const CLIENTS_VISIBLE_ITEMS = 4;
/** Filler rows, used to push a list past the card's visible limit. */
const fillers = (count: number) =>
Array.from({ length: count }, (_, index) => ({ name: `filler-${index}`, count: 1 }));
describe('Dashboard "Show more" links', () => {
beforeEach(() => {
// Drives `useIsDesktop` (768px breakpoint) for the responsive assertions.
mockMatchMedia(false);
});
it('Top clients card links to /top_clients', () => {
renderWithRouter(() => (
<TopClients topClients={[{ name: '10.0.0.1', count: 5 }]} numDnsQueries={100} />
<TopClients
topClients={[{ name: '10.0.0.1', count: 5 }, ...fillers(CLIENTS_VISIBLE_ITEMS)]}
numDnsQueries={100}
/>
));
expect(screen.getByText(copyInDom('show_more'))).toBeInTheDocument();
expect(getLinkHref('show-more-top-clients')).toContain('/top_clients');
@@ -32,42 +64,68 @@ describe('Dashboard "Show more" links', () => {
it('Top queried domains card links to /top_queried_domains', () => {
renderWithRouter(() => (
<TopQueriedDomains
topQueriedDomains={[{ name: 'a.org', count: 5 }]}
topQueriedDomains={[{ name: 'a.org', count: 5 }, ...fillers(DOMAINS_VISIBLE_ITEMS)]}
numDnsQueries={100}
/>
));
expect(getLinkHref('show-more-top-queried-domains')).toContain('/top_queried_domains');
// The domain name is a QueryLog link filtered by the domain.
const domainLink = screen.getByText('a.org').closest('a');
expect(domainLink).not.toBeNull();
expect(domainLink!.getAttribute('href')).toContain('/logs');
expect(domainLink!.getAttribute('href')).toContain('a.org');
const domainLink = getDomainRow('a.org');
expect(domainLink.tagName).toBe('A');
expect(domainLink.getAttribute('href')).toContain('/logs');
expect(domainLink.getAttribute('href')).toContain('a.org');
});
it('Top blocked domains card links to /top_blocked_domains', () => {
renderWithRouter(() => (
<TopBlockedDomains
topBlockedDomains={[{ name: 'a.org', count: 5 }]}
topBlockedDomains={[{ name: 'a.org', count: 5 }, ...fillers(DOMAINS_VISIBLE_ITEMS)]}
numBlockedFiltering={100}
/>
));
expect(getLinkHref('show-more-top-blocked-domains')).toContain('/top_blocked_domains');
const domainLink = screen.getByText('a.org').closest('a');
expect(domainLink).not.toBeNull();
expect(domainLink!.getAttribute('href')).toContain('/logs');
expect(domainLink!.getAttribute('href')).toContain('a.org');
const domainLink = getDomainRow('a.org');
expect(domainLink.tagName).toBe('A');
expect(domainLink.getAttribute('href')).toContain('/logs');
expect(domainLink.getAttribute('href')).toContain('a.org');
// The blocked total links to QueryLog filtered by blocked status.
expect(getLinkHref('blocked-total-link')).toContain('/logs');
expect(getLinkHref('blocked-total-link')).toContain('status=blocked');
});
it('Top blocked domains shows only the number of blocked queries on mobile', () => {
renderWithRouter(() => (
<TopBlockedDomains
topBlockedDomains={[{ name: 'a.org', count: 5 }]}
numBlockedFiltering={100}
/>
));
expect(screen.getByTestId('blocked-total-link').textContent).toBe(formatCompactNumber(100));
});
it('Top blocked domains shows the blocked total copy on desktop', () => {
mockMatchMedia(true);
renderWithRouter(() => (
<TopBlockedDomains
topBlockedDomains={[{ name: 'a.org', count: 5 }]}
numBlockedFiltering={100}
/>
));
expect(screen.getByTestId('blocked-total-link').textContent).toBe(
copyInDom('blocked_total', { value: formatCompactNumber(100) }),
);
});
it('Top upstreams card links to /top_upstreams', () => {
renderWithRouter(() => (
<TopUpstreams
topUpstreamsResponses={[{ name: 'tls://x:853', count: 5 }]}
topUpstreamsResponses={[
{ name: 'tls://x:853', count: 5 },
...fillers(DOMAINS_VISIBLE_ITEMS),
]}
numDnsQueries={100}
/>
));
@@ -77,22 +135,78 @@ describe('Dashboard "Show more" links', () => {
it('Average upstream response time card links to /upstream_avg_time', () => {
renderWithRouter(() => (
<UpstreamAvgTime
topUpstreamsAvgTime={[{ name: 'tls://x:853', count: 12 }]}
topUpstreamsAvgTime={[
{ name: 'tls://x:853', count: 12 },
...fillers(DOMAINS_VISIBLE_ITEMS),
]}
avgProcessingTime={12}
/>
));
expect(getLinkHref('show-more-upstream-avg-time')).toContain('/upstream_avg_time');
});
it('the link is visible even when the card is empty', () => {
it('the link is hidden when the card shows every row it has', () => {
renderWithRouter(() => (
<TopQueriedDomains
topQueriedDomains={fillers(DOMAINS_VISIBLE_ITEMS)}
numDnsQueries={100}
/>
));
expect(screen.queryByTestId('show-more-top-queried-domains')).toBeNull();
});
it('the link is hidden when the card is empty', () => {
renderWithRouter(() => <TopQueriedDomains topQueriedDomains={[]} numDnsQueries={100} />);
expect(getLinkHref('show-more-top-queried-domains')).toContain('/top_queried_domains');
expect(screen.queryByTestId('show-more-top-queried-domains')).toBeNull();
});
it('the link is hidden for top blocked domains with no hidden rows', () => {
renderWithRouter(() => (
<TopBlockedDomains
topBlockedDomains={fillers(DOMAINS_VISIBLE_ITEMS)}
numBlockedFiltering={100}
/>
));
expect(screen.queryByTestId('show-more-top-blocked-domains')).toBeNull();
});
it('the link is hidden for top upstreams with no hidden rows', () => {
renderWithRouter(() => (
<TopUpstreams
topUpstreamsResponses={fillers(DOMAINS_VISIBLE_ITEMS)}
numDnsQueries={100}
/>
));
expect(screen.queryByTestId('show-more-top-upstreams')).toBeNull();
});
it('the link is hidden for average upstream response time with no hidden rows', () => {
renderWithRouter(() => (
<UpstreamAvgTime
topUpstreamsAvgTime={fillers(DOMAINS_VISIBLE_ITEMS)}
avgProcessingTime={12}
/>
));
expect(screen.queryByTestId('show-more-upstream-avg-time')).toBeNull();
});
it('Top clients shows the link once a fifth client is hidden', () => {
const { unmount } = renderWithRouter(() => (
<TopClients topClients={fillers(CLIENTS_VISIBLE_ITEMS)} numDnsQueries={100} />
));
expect(screen.queryByTestId('show-more-top-clients')).toBeNull();
unmount();
renderWithRouter(() => (
<TopClients topClients={fillers(CLIENTS_VISIBLE_ITEMS + 1)} numDnsQueries={100} />
));
expect(getLinkHref('show-more-top-clients')).toContain('/top_clients');
});
it('the link includes the selected stats period', () => {
renderWithRouter(() => (
<TopBlockedDomains
topBlockedDomains={[{ name: 'a.org', count: 5 }]}
topBlockedDomains={[{ name: 'a.org', count: 5 }, ...fillers(DOMAINS_VISIBLE_ITEMS)]}
numBlockedFiltering={100}
period={DAY}
/>
@@ -0,0 +1,160 @@
import { render, screen, fireEvent, waitFor, within } from '@solidjs/testing-library';
import { HashRouter, Route } from '@solidjs/router';
import { describe, it, expect, beforeEach } from 'vitest';
import { GeneralStatistics } from 'panel/components/Dashboard/blocks/GeneralStatistics';
import { QUERY_LOG_REASON_FILTER, QUERY_LOG_STATUS_FILTER } from 'panel/helpers/constants';
import { formatNumber } from 'panel/helpers/helpers';
import { copy } from 'panel/__tests__/helpers/copy';
import { mockMatchMedia } from 'panel/__tests__/helpers/matchMedia';
/**
* Counts are deliberately above `QueriesTooltip`'s threshold (1000) and the
* blocked counts give round percentages, so the value cell is easy to assert on.
*/
const STATS = {
numDnsQueries: 2000,
numBlockedFiltering: 500,
numReplacedSafebrowsing: 100,
numReplacedParental: 40,
numReplacedSafesearch: 8,
avgProcessingTime: 152,
};
/** Every row, in the order `GeneralStatistics` renders them. */
const ROWS = [
{ label: copy('dns_queries'), description: copy('dns_queries_tooltip') },
{ label: copy('ads_blocked'), description: copy('ads_blocked_tooltip') },
{ label: copy('threats_blocked'), description: copy('threats_blocked_tooltip') },
{ label: copy('adult_websites_blocked'), description: copy('adult_websites_blocked_tooltip') },
{ label: copy('safe_search_used'), description: copy('safe_search_used_tooltip') },
{
label: copy('average_time_processing'),
description: copy('average_time_processing_tooltip'),
},
];
const renderCard = () =>
render(() => (
<HashRouter>
<Route path="/" component={() => <GeneralStatistics {...STATS} />} />
</HashRouter>
));
/** The tooltip trigger of the row whose label is `label`. */
const rowTrigger = (label: string) => {
const trigger = screen.getByText(label).closest('[data-part="trigger"]');
expect(trigger).not.toBeNull();
return trigger as HTMLElement;
};
/** The tooltip content of the row whose description is `description`. */
const tooltipContent = (description: string) => {
const content = screen.getByText(description).closest('[data-part="content"]');
expect(content).not.toBeNull();
return content as HTMLElement;
};
const hover = async (target: HTMLElement, description: string) => {
const content = tooltipContent(description);
expect(content.hasAttribute('hidden')).toBe(true);
fireEvent.pointerOver(target, { pointerType: 'mouse' });
// SHOW_DELAY is 200 ms; real timers, so use a generous timeout.
await waitFor(() => expect(content.hasAttribute('hidden')).toBe(false), { timeout: 1000 });
};
describe('Dashboard general statistics row tooltips', () => {
beforeEach(() => {
// The global setup mock answers `false` to every query, i.e. a desktop
// without hover media support, matching the Tooltip tests.
mockMatchMedia(false);
});
it('makes the whole row the tooltip trigger, not just the label', () => {
renderCard();
// The value cell (and its bar) are inside the trigger, so hovering the
// number or the bar reveals the same description as hovering the label.
const dnsRow = rowTrigger(copy('dns_queries'));
expect(within(dnsRow).getByText(`(${copy('total')})`)).toBeInTheDocument();
expect(dnsRow.querySelector('[class*="queryBar"]')).not.toBeNull();
const blockedRow = rowTrigger(copy('ads_blocked'));
expect(within(blockedRow).getByText('(25.0%)')).toBeInTheDocument();
});
it('opens the description when hovering the value area of a row', async () => {
renderCard();
const value = within(rowTrigger(copy('dns_queries'))).getByText(`(${copy('total')})`);
await hover(value, copy('dns_queries_tooltip'));
});
it.each(ROWS)('opens the description of the $label row on hover', async (row) => {
renderCard();
await hover(rowTrigger(row.label), row.description);
});
it('renders a single tooltip per row', () => {
const { container } = renderCard();
expect(container.querySelectorAll('[data-part="trigger"]')).toHaveLength(ROWS.length);
// The nested `QueriesTooltip` is gone: the exact count is no longer
// revealed, because it would open on top of the row description.
expect(
screen.queryByText(copy('queries_tooltip', { value: formatNumber(2000) })),
).toBeNull();
});
it('keeps the row link to the query log', () => {
renderCard();
const dnsRow = screen.getByText(copy('dns_queries')).closest('a');
expect(dnsRow?.getAttribute('href')).toContain('/logs');
const blockedRow = screen.getByText(copy('ads_blocked')).closest('a');
const blockedHref = blockedRow?.getAttribute('href') ?? '';
expect(blockedHref).toContain(QUERY_LOG_STATUS_FILTER.BLOCKED.QUERY);
expect(blockedHref).toContain(QUERY_LOG_REASON_FILTER.BLOCKED_BY_FILTER.QUERY);
});
it('keeps the average processing time row a plain row with its value', () => {
renderCard();
const label = screen.getByText(copy('average_time_processing'));
expect(label.closest('a')).toBeNull();
expect(
within(rowTrigger(copy('average_time_processing'))).getByText(
copy('processing_time_ms', { value: '152' }),
),
).toBeInTheDocument();
});
describe('touch device', () => {
beforeEach(() => {
mockMatchMedia((query) => query === '(hover: none)');
});
it('renders no tooltips at all', () => {
const { container } = renderCard();
expect(container.querySelector('[data-scope="tooltip"]')).toBeNull();
expect(container.querySelectorAll('[data-part="trigger"]')).toHaveLength(0);
});
it('still renders the rows as query log links', () => {
renderCard();
const dnsRow = screen.getByText(copy('dns_queries')).closest('a');
expect(dnsRow?.getAttribute('href')).toContain('/logs');
});
});
});
@@ -0,0 +1,121 @@
import { render, screen, fireEvent } from '@solidjs/testing-library';
import { HashRouter, Route } from '@solidjs/router';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
const mocks = vi.hoisted(() => ({
accessList: vi.fn(),
accessSet: vi.fn(),
}));
vi.mock('panel/api/generated', () => ({
accessList: mocks.accessList,
accessSet: mocks.accessSet,
}));
import { TopClients } from 'panel/components/Dashboard/blocks/TopClients';
import { getAccessList } from 'panel/stores/access';
import { CLIENT_BLOCK_CONFIRM_SUBMIT_TEST_ID } from 'panel/common/ui/ClientBlockConfirm';
import theme from 'panel/lib/theme';
import { copyInDom } from 'panel/__tests__/helpers/copy';
import { mockMatchMedia } from 'panel/__tests__/helpers/matchMedia';
const CLIENT = '10.0.0.1';
const renderCard = () =>
render(() => (
<HashRouter>
<Route
path="/"
component={() => (
<TopClients topClients={[{ name: CLIENT, count: 5 }]} numDnsQueries={100} />
)}
/>
</HashRouter>
));
/** The inline mobile action; the closed desktop dropdown renders a second copy. */
const actionButton = (key: 'block_client' | 'unblock_client') =>
screen.getAllByRole('button', { name: copyInDom(key) })[0];
const loadAccessList = (disallowed: string[]) => {
mocks.accessList.mockResolvedValue({
allowed_clients: [],
disallowed_clients: disallowed,
blocked_hosts: [],
});
return getAccessList();
};
describe('Dashboard top clients block/unblock action', () => {
beforeEach(() => {
vi.clearAllMocks();
mockMatchMedia(false);
});
it('renders the block action as a button without the dropdown item background', async () => {
await loadAccessList([]);
renderCard();
const button = actionButton('block_client');
expect(button.tagName).toBe('BUTTON');
expect(button.getAttribute('type')).toBe('button');
// The dropdown row class paints `--default-dropdown-menu-background`.
expect(theme.dropdown.item).toBeTruthy();
expect(button.classList.contains(theme.dropdown.item)).toBe(false);
// Red is the destructive block action.
expect(button.className).toMatch(/tableRowMenuItemRed/);
});
it('renders the unblock action as a button without the dropdown item background', async () => {
await loadAccessList([CLIENT]);
renderCard();
const button = actionButton('unblock_client');
expect(button.tagName).toBe('BUTTON');
expect(button.getAttribute('type')).toBe('button');
expect(button.classList.contains(theme.dropdown.item)).toBe(false);
// Green is the restoring unblock action.
expect(button.className).toMatch(/tableRowMenuItemGreen/);
});
it('opens the confirm dialog from the inline action button', async () => {
await loadAccessList([]);
renderCard();
await userEvent.click(actionButton('block_client'));
expect(screen.getByTestId(CLIENT_BLOCK_CONFIRM_SUBMIT_TEST_ID)).toBeInTheDocument();
});
it('switches the action when the client becomes blocked', async () => {
await loadAccessList([]);
renderCard();
expect(actionButton('block_client')).toBeInTheDocument();
await loadAccessList([CLIENT]);
expect(actionButton('unblock_client')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: copyInDom('block_client') })).toBeNull();
});
it('does not scroll the page to the top when the action opens the dialog', async () => {
await loadAccessList([]);
renderCard();
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
vi.useFakeTimers();
try {
fireEvent.click(actionButton('block_client'));
// The row link scrolls to the top 100ms after a click.
vi.advanceTimersByTime(150);
expect(screen.getByTestId(CLIENT_BLOCK_CONFIRM_SUBMIT_TEST_ID)).toBeInTheDocument();
expect(scrollTo).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
scrollTo.mockRestore();
}
});
});
@@ -19,7 +19,12 @@ vi.mock('panel/common/intl', () => ({
default: { getMessage: vi.fn((key: string) => key) },
}));
import { getDnsConfig, dnsConfigState, toggleResolveClients } from 'panel/stores/dnsConfig';
import {
getDnsConfig,
dnsConfigState,
togglePrivatePtrResolvers,
toggleResolveClients,
} from 'panel/stores/dnsConfig';
describe('getDnsConfig', () => {
beforeEach(() => vi.clearAllMocks());
@@ -62,3 +67,64 @@ describe('toggleResolveClients', () => {
});
});
});
describe('togglePrivatePtrResolvers', () => {
beforeEach(() => vi.clearAllMocks());
const loadConfig = async (config: {
use_private_ptr_resolvers: boolean;
local_ptr_upstreams: string[];
}) => {
mocks.dnsInfo.mockResolvedValue(config);
await getDnsConfig();
};
it('sends the effective servers when enabling', async () => {
mocks.dnsConfig.mockResolvedValue({});
await loadConfig({
use_private_ptr_resolvers: false,
local_ptr_upstreams: ['1.1.1.1', '# comment'],
});
await togglePrivatePtrResolvers();
// The backend validates the request body on its own, so the servers
// have to travel with the flag.
expect(mocks.dnsConfig).toHaveBeenCalledWith({
use_private_ptr_resolvers: true,
local_ptr_upstreams: ['1.1.1.1', '# comment'],
});
});
it('sends only the flag when no servers are configured', async () => {
mocks.dnsConfig.mockResolvedValue({});
await loadConfig({ use_private_ptr_resolvers: false, local_ptr_upstreams: [] });
await togglePrivatePtrResolvers();
expect(mocks.dnsConfig).toHaveBeenCalledWith({ use_private_ptr_resolvers: true });
});
it('does not send the servers when disabling', async () => {
mocks.dnsConfig.mockResolvedValue({});
await loadConfig({
use_private_ptr_resolvers: true,
local_ptr_upstreams: ['1.1.1.1'],
});
await togglePrivatePtrResolvers();
expect(mocks.dnsConfig).toHaveBeenCalledWith({ use_private_ptr_resolvers: false });
});
it('drops a toggle while another save is in flight', async () => {
// Never settles, so `processingSetConfig` stays true.
mocks.dnsConfig.mockReturnValue(new Promise(() => {}));
await loadConfig({ use_private_ptr_resolvers: false, local_ptr_upstreams: [] });
togglePrivatePtrResolvers();
await togglePrivatePtrResolvers();
expect(mocks.dnsConfig).toHaveBeenCalledTimes(1);
});
});
@@ -113,7 +113,7 @@ describe('DynamicLeasesTab', () => {
fireEvent.click(screen.getByTestId('dynamic-lease-delete-button'));
// Click the confirm button
const confirmButton = screen.getByText('delete_table_action_confirm');
const confirmButton = screen.getByText('yes_remove');
fireEvent.click(confirmButton);
expect(mocks.removeStaticLease).toHaveBeenCalledWith(MOCK_LEASE);
@@ -0,0 +1,91 @@
type Listener = (event: MediaQueryListEvent) => void;
/**
* How a stubbed `window.matchMedia` should answer a query: a single boolean for
* every query, or a predicate for per-query control.
*/
export type MatchMediaResult = boolean | ((query: string) => boolean);
export interface MatchMediaMock {
/**
* Replaces the current result and notifies every registered `change`
* listener, which is how the reactive hooks observe a viewport change.
*/
set: (next: MatchMediaResult) => void;
/**
* Number of live `change` listeners across the media queries whose text
* contains `queryFragment` (all of them when it is omitted). Use it to
* assert that a hook unsubscribes on unmount.
*/
listenerCount: (queryFragment?: string) => number;
}
/** Builds a predicate that emulates a viewport of `width` pixels wide. */
export const byViewportWidth =
(width: number): MatchMediaResult =>
(query) => {
const minWidth = /\(min-width:\s*(\d+)px\)/.exec(query);
return minWidth ? width >= Number(minWidth[1]) : false;
};
/**
* Installs a controllable `window.matchMedia` stub — jsdom does not implement
* it. Pass a boolean to make every query match (or not match), or a predicate
* to answer per query, e.g. `byViewportWidth(900)`.
*
* The reactive hooks in `panel/hooks/useMediaQuery` read `matches` both when
* they are created and again on mount, so the stub keeps the value live behind
* a getter instead of copying it into each `MediaQueryList`.
*/
export const mockMatchMedia = (initial: MatchMediaResult = false): MatchMediaMock => {
let current = initial;
const created: { query: string; listeners: Set<Listener> }[] = [];
const resolve = (query: string) =>
typeof current === 'boolean' ? current : current(query);
const createMediaQueryList = (query: string): MediaQueryList => {
const record = { query, listeners: new Set<Listener>() };
created.push(record);
return {
get matches() {
return resolve(query);
},
media: query,
onchange: null,
addEventListener: (type: string, listener: Listener) => {
if (type === 'change') record.listeners.add(listener);
},
removeEventListener: (type: string, listener: Listener) => {
if (type === 'change') record.listeners.delete(listener);
},
addListener: (listener: Listener) => record.listeners.add(listener),
removeListener: (listener: Listener) => record.listeners.delete(listener),
dispatchEvent: () => false,
} as unknown as MediaQueryList;
};
Object.defineProperty(window, 'matchMedia', {
writable: true,
configurable: true,
value: createMediaQueryList,
});
return {
set: (next) => {
current = next;
created.forEach(({ query, listeners }) => {
const event = { matches: resolve(query), media: query } as MediaQueryListEvent;
listeners.forEach((listener) => listener(event));
});
},
listenerCount: (queryFragment) =>
created
.filter(({ query }) => !queryFragment || query.includes(queryFragment))
.reduce((count, { listeners }) => count + listeners.size, 0),
};
};
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from 'vitest';
import { redirectToCurrentProtocol } from 'panel/helpers/helpers';
/**
* Replaces the jsdom location with a plain object so `replace()` is a spy.
* `redirectToCurrentProtocol` reads the location at call time.
*/
const mockLocation = (props: {
protocol: string;
hostname: string;
hash: string;
port?: string;
}) => {
const replace = vi.fn();
Object.defineProperty(window, 'location', {
value: {
protocol: props.protocol,
hostname: props.hostname,
hash: props.hash,
port: props.port ?? '',
replace,
},
writable: true,
configurable: true,
});
return replace;
};
describe('redirectToCurrentProtocol', () => {
it('navigates from https to the HTTP UI when encryption is disabled', () => {
const replace = mockLocation({
protocol: 'https:',
hostname: 'dns.example.com',
hash: '#/encryption',
port: '443',
});
redirectToCurrentProtocol({ enabled: false, force_https: false, port_https: 443 }, 3001);
expect(replace).toHaveBeenCalledWith('http://dns.example.com:3001/#/encryption');
});
it('stays put when encryption is off on an http origin', () => {
const replace = mockLocation({
protocol: 'http:',
hostname: 'dns.example.com',
hash: '#/encryption',
});
redirectToCurrentProtocol({ enabled: false, force_https: false, port_https: 443 }, 80);
expect(replace).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DAY, HOUR } from 'panel/helpers/constants';
import { LocalStorageHelper, LOCAL_STORAGE_KEYS } from 'panel/helpers/localStorageHelper';
import {
clampStatsPeriod,
getEffectiveStatsPeriod,
getStoredStatsPeriod,
resolveStatsPeriod,
} from 'panel/helpers/statistics';
const storePeriod = (period: number) =>
LocalStorageHelper.setItem(LOCAL_STORAGE_KEYS.STATS_PERIOD, period);
describe('clampStatsPeriod', () => {
it('clamps a period above the max interval down to it', () => {
expect(clampStatsPeriod(DAY * 30, DAY * 7)).toBe(DAY * 7);
});
it('keeps a period within the max interval as is', () => {
expect(clampStatsPeriod(HOUR * 6, DAY * 7)).toBe(HOUR * 6);
});
it('falls back to DAY when the max interval is unexpectedly small', () => {
expect(clampStatsPeriod(DAY * 30, 0)).toBe(DAY);
});
});
describe('getStoredStatsPeriod', () => {
beforeEach(() => localStorage.clear());
it('defaults to DAY when nothing is stored', () => {
expect(getStoredStatsPeriod()).toBe(DAY);
});
it('returns the stored period', () => {
storePeriod(DAY * 30);
expect(getStoredStatsPeriod()).toBe(DAY * 30);
});
it('ignores a non-positive stored value', () => {
storePeriod(0);
expect(getStoredStatsPeriod()).toBe(DAY);
});
});
describe('getEffectiveStatsPeriod', () => {
beforeEach(() => localStorage.clear());
it('clamps the stored period by the max interval', () => {
storePeriod(DAY * 90);
expect(getEffectiveStatsPeriod(DAY * 7)).toBe(DAY * 7);
});
it('keeps the stored period when it is within the max interval', () => {
storePeriod(DAY * 7);
expect(getEffectiveStatsPeriod(DAY * 90)).toBe(DAY * 7);
});
});
describe('resolveStatsPeriod', () => {
beforeEach(() => localStorage.clear());
it('prefers a valid URL period', () => {
storePeriod(DAY);
expect(resolveStatsPeriod({ period: String(HOUR * 6) }, DAY * 30)).toBe(HOUR * 6);
});
it('clamps a URL period above the max interval', () => {
expect(resolveStatsPeriod({ period: String(DAY * 90) }, DAY * 30)).toBe(DAY * 30);
});
it('falls back to the stored period when the URL period is invalid', () => {
storePeriod(DAY * 7);
expect(resolveStatsPeriod({ period: 'nope' }, DAY * 30)).toBe(DAY * 7);
});
});
@@ -1,3 +1,4 @@
import { createSignal } from 'solid-js';
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@solidjs/testing-library';
@@ -27,15 +28,18 @@ const restoreOverflow = () => {
let observerCallback: ResizeObserverCallback | undefined;
let observerDisconnected = false;
let observerCount = 0;
/** Replaces the global no-op `ResizeObserver` from `src/__tests__/setup.ts`. */
const installResizeObserver = () => {
observerCallback = undefined;
observerDisconnected = false;
observerCount = 0;
class FakeResizeObserver {
constructor(callback: ResizeObserverCallback) {
observerCallback = callback;
observerCount += 1;
}
observe() {}
@@ -62,6 +66,22 @@ const Harness = () => {
);
};
/** The same probe, with the gating options driven by signals from the test. */
const GatedHarness = (props: { enabled: boolean; text: string }) => {
let labelRef: HTMLSpanElement | undefined;
const isTruncated = useIsTruncated(() => labelRef, {
enabled: () => props.enabled,
source: () => props.text,
});
return (
<span ref={labelRef} data-testid="label">
{isTruncated() ? 'truncated' : 'fits'}
</span>
);
};
describe('useIsTruncated', () => {
afterEach(() => {
restoreOverflow();
@@ -122,4 +142,58 @@ describe('useIsTruncated', () => {
expect(observerDisconnected).toBe(true);
});
it('neither measures nor observes while disabled', () => {
stubOverflow(200, 100);
installResizeObserver();
render(() => <GatedHarness enabled={false} text="a" />);
expect(observerCount).toBe(0);
expect(screen.getByTestId('label')).toHaveTextContent('fits');
});
it('measures and observes once enabled', () => {
stubOverflow(200, 100);
installResizeObserver();
const [enabled, setEnabled] = createSignal(false);
render(() => <GatedHarness enabled={enabled()} text="a" />);
expect(screen.getByTestId('label')).toHaveTextContent('fits');
setEnabled(true);
expect(observerCount).toBe(1);
expect(screen.getByTestId('label')).toHaveTextContent('truncated');
});
it('stops observing and resets once disabled again', () => {
stubOverflow(200, 100);
installResizeObserver();
const [enabled, setEnabled] = createSignal(true);
render(() => <GatedHarness enabled={enabled()} text="a" />);
expect(observerDisconnected).toBe(false);
setEnabled(false);
expect(observerDisconnected).toBe(true);
expect(screen.getByTestId('label')).toHaveTextContent('fits');
});
it('re-measures when the source changes without a box resize', () => {
stubOverflow(100, 100);
installResizeObserver();
const [text, setText] = createSignal('a');
render(() => <GatedHarness enabled text={text()} />);
expect(screen.getByTestId('label')).toHaveTextContent('fits');
stubOverflow(200, 100);
setText('a much longer name');
expect(screen.getByTestId('label')).toHaveTextContent('truncated');
// The observer follows the element, not the text, so it must survive.
expect(observerCount).toBe(1);
});
});
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest';
import { render, screen, cleanup } from '@solidjs/testing-library';
import { useIsDesktop, useIsMobile, useMediaQuery, useIsTouchDevice } from 'panel/hooks/useMediaQuery';
import { byViewportWidth, mockMatchMedia } from 'panel/__tests__/helpers/matchMedia';
const Harness = () => {
const isDesktop = useIsDesktop();
const isMobile = useIsMobile();
const isTouch = useIsTouchDevice();
const isWide = useMediaQuery('(min-width: 1200px)');
return (
<div
data-testid="harness"
data-desktop={String(isDesktop())}
data-mobile={String(isMobile())}
data-touch={String(isTouch())}
data-wide={String(isWide())}
/>
);
};
/** Renders the harness against a simulated `width`-pixel-wide viewport. */
const renderAt = (width: number) => {
mockMatchMedia(byViewportWidth(width));
render(() => <Harness />);
return screen.getByTestId('harness');
};
describe('useMediaQuery', () => {
it('reports the current match of the given query', () => {
expect(renderAt(1300)).toHaveAttribute('data-wide', 'true');
});
it('reports false for a query that does not match', () => {
expect(renderAt(900)).toHaveAttribute('data-wide', 'false');
});
it('re-evaluates when the media query result changes', () => {
const media = mockMatchMedia(byViewportWidth(500));
render(() => <Harness />);
expect(screen.getByTestId('harness')).toHaveAttribute('data-desktop', 'false');
media.set(byViewportWidth(1300));
expect(screen.getByTestId('harness')).toHaveAttribute('data-wide', 'true');
});
it('unsubscribes from the media query on unmount', () => {
const media = mockMatchMedia(byViewportWidth(1300));
render(() => <Harness />);
expect(media.listenerCount()).toBeGreaterThan(0);
cleanup();
expect(media.listenerCount()).toBe(0);
});
});
describe('useIsDesktop', () => {
it('reports false just below the 768px breakpoint', () => {
expect(renderAt(767)).toHaveAttribute('data-desktop', 'false');
});
it('reports true from the 768px breakpoint up', () => {
expect(renderAt(768)).toHaveAttribute('data-desktop', 'true');
});
});
describe('useIsMobile', () => {
it('reports true just below the 1024px breakpoint', () => {
expect(renderAt(1023)).toHaveAttribute('data-mobile', 'true');
});
it('reports false from the 1024px breakpoint up', () => {
expect(renderAt(1024)).toHaveAttribute('data-mobile', 'false');
});
it('follows the viewport when it crosses the breakpoint', () => {
const media = mockMatchMedia(byViewportWidth(500));
render(() => <Harness />);
expect(screen.getByTestId('harness')).toHaveAttribute('data-mobile', 'true');
media.set(byViewportWidth(1100));
expect(screen.getByTestId('harness')).toHaveAttribute('data-mobile', 'false');
});
/**
* `useIsMobile` and `useIsDesktop` deliberately use different breakpoints,
* so mid-size viewports are reported by both hooks at once. Every call site
* relies on this, so pin it before "fixing" either hook.
*/
it('overlaps with useIsDesktop between 768px and 1023px', () => {
const harness = renderAt(900);
expect(harness).toHaveAttribute('data-desktop', 'true');
expect(harness).toHaveAttribute('data-mobile', 'true');
});
});
describe('useIsTouchDevice', () => {
it('reports true when the device cannot hover', () => {
mockMatchMedia((query) => query === '(hover: none)');
render(() => <Harness />);
expect(screen.getByTestId('harness')).toHaveAttribute('data-touch', 'true');
});
it('reports false on devices with hover support', () => {
expect(renderAt(1300)).toHaveAttribute('data-touch', 'false');
});
});
@@ -0,0 +1,215 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@solidjs/testing-library';
import { useScrollEdges } from 'panel/hooks/useScrollEdges';
/**
* jsdom reports 0 for every scroll metric, so the scroll range has to be
* injected. The properties live on `Element.prototype`, so shadowing them on
* `HTMLElement.prototype` and deleting the shadow restores the originals.
*/
type Metrics = { scrollTop: number; scrollHeight: number; clientHeight: number };
let metrics: Metrics = { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
const installScrollMetrics = (next: Partial<Metrics> = {}) => {
metrics = { scrollTop: 0, scrollHeight: 0, clientHeight: 0, ...next };
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => metrics.scrollTop,
set: (value: number) => {
metrics.scrollTop = value;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => metrics.scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get: () => metrics.clientHeight,
});
};
const restoreScrollMetrics = () => {
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTop');
Reflect.deleteProperty(HTMLElement.prototype, 'scrollHeight');
Reflect.deleteProperty(HTMLElement.prototype, 'clientHeight');
};
let observerCallback: ResizeObserverCallback | undefined;
let observedTargets: Element[] = [];
let disconnectCount = 0;
/** Replaces the global no-op `ResizeObserver` from `src/__tests__/setup.ts`. */
const installResizeObserver = () => {
observerCallback = undefined;
observedTargets = [];
disconnectCount = 0;
class FakeResizeObserver {
constructor(callback: ResizeObserverCallback) {
observerCallback = callback;
}
observe(target: Element) {
observedTargets.push(target);
}
unobserve() {}
disconnect() {
disconnectCount += 1;
}
}
window.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver;
};
const Harness = () => {
let scrollRef: HTMLDivElement | undefined;
const { canScrollUp, canScrollDown } = useScrollEdges(() => scrollRef);
return (
<div
ref={scrollRef}
data-testid="scroll-area"
data-can-scroll-up={String(canScrollUp())}
data-can-scroll-down={String(canScrollDown())}
>
<div data-testid="content" />
</div>
);
};
const WithoutElement = () => {
const { canScrollUp, canScrollDown } = useScrollEdges(() => undefined);
return (
<div
data-testid="scroll-area"
data-can-scroll-up={String(canScrollUp())}
data-can-scroll-down={String(canScrollDown())}
/>
);
};
const scrollArea = () => screen.getByTestId('scroll-area');
describe('useScrollEdges', () => {
afterEach(() => {
restoreScrollMetrics();
});
it('reports no scrollable edges while the element is missing', () => {
installScrollMetrics({ scrollHeight: 400, clientHeight: 100 });
installResizeObserver();
render(() => <WithoutElement />);
expect(scrollArea()).toHaveAttribute('data-can-scroll-up', 'false');
expect(scrollArea()).toHaveAttribute('data-can-scroll-down', 'false');
});
it('reports no scrollable edges when the content fits', () => {
installScrollMetrics({ scrollHeight: 400, clientHeight: 400 });
installResizeObserver();
render(() => <Harness />);
expect(scrollArea()).toHaveAttribute('data-can-scroll-up', 'false');
expect(scrollArea()).toHaveAttribute('data-can-scroll-down', 'false');
});
it('reports only the bottom edge at the very top', () => {
installScrollMetrics({ scrollTop: 0, scrollHeight: 400, clientHeight: 100 });
installResizeObserver();
render(() => <Harness />);
expect(scrollArea()).toHaveAttribute('data-can-scroll-up', 'false');
expect(scrollArea()).toHaveAttribute('data-can-scroll-down', 'true');
});
it('reports both edges in the middle', () => {
installScrollMetrics({ scrollTop: 150, scrollHeight: 400, clientHeight: 100 });
installResizeObserver();
render(() => <Harness />);
expect(scrollArea()).toHaveAttribute('data-can-scroll-up', 'true');
expect(scrollArea()).toHaveAttribute('data-can-scroll-down', 'true');
});
it('reports only the top edge at the very bottom', () => {
installScrollMetrics({ scrollTop: 300, scrollHeight: 400, clientHeight: 100 });
installResizeObserver();
render(() => <Harness />);
expect(scrollArea()).toHaveAttribute('data-can-scroll-up', 'true');
expect(scrollArea()).toHaveAttribute('data-can-scroll-down', 'false');
});
it('ignores a sub-pixel remainder at the edges', () => {
installScrollMetrics({ scrollTop: 0.5, scrollHeight: 400, clientHeight: 399.5 });
installResizeObserver();
render(() => <Harness />);
expect(scrollArea()).toHaveAttribute('data-can-scroll-up', 'false');
expect(scrollArea()).toHaveAttribute('data-can-scroll-down', 'false');
});
it('re-measures on scroll', () => {
installScrollMetrics({ scrollTop: 0, scrollHeight: 400, clientHeight: 100 });
installResizeObserver();
render(() => <Harness />);
expect(scrollArea()).toHaveAttribute('data-can-scroll-up', 'false');
metrics.scrollTop = 300;
fireEvent.scroll(scrollArea());
expect(scrollArea()).toHaveAttribute('data-can-scroll-up', 'true');
expect(scrollArea()).toHaveAttribute('data-can-scroll-down', 'false');
});
it('re-measures when the observed element resizes', () => {
installScrollMetrics({ scrollTop: 0, scrollHeight: 400, clientHeight: 100 });
installResizeObserver();
render(() => <Harness />);
expect(scrollArea()).toHaveAttribute('data-can-scroll-down', 'true');
// The content grew taller while the scroll position stayed put.
metrics.scrollHeight = 100;
observerCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
expect(scrollArea()).toHaveAttribute('data-can-scroll-down', 'false');
});
it('observes the container together with its content', () => {
installScrollMetrics({ scrollTop: 0, scrollHeight: 400, clientHeight: 100 });
installResizeObserver();
render(() => <Harness />);
expect(observedTargets).toContain(scrollArea());
expect(observedTargets).toContain(screen.getByTestId('content'));
});
it('disconnects the observer on cleanup', () => {
installScrollMetrics({ scrollTop: 0, scrollHeight: 400, clientHeight: 100 });
installResizeObserver();
render(() => <Harness />);
const before = disconnectCount;
cleanup();
expect(disconnectCount).toBeGreaterThan(before);
});
});
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { render, fireEvent } from '@solidjs/testing-library';
import { createSignal, type Accessor } from 'solid-js';
import { useSwitchState } from 'panel/hooks/useSwitchState';
type HarnessProps = {
value: Accessor<boolean>;
processing: Accessor<boolean>;
/** Stands in for a handler that rejects the toggle. */
reject?: (setChecked: (next: boolean) => void) => void;
};
const Harness = (props: HarnessProps) => {
const [checked, setChecked] = useSwitchState(
() => props.value(),
() => props.processing(),
);
return (
<input
id="switch"
type="checkbox"
checked={checked()}
onChange={() => props.reject?.(setChecked)}
/>
);
};
const getInput = () => document.querySelector('#switch') as HTMLInputElement;
describe('useSwitchState', () => {
it('renders the store value', () => {
render(() => <Harness value={() => true} processing={() => false} />);
expect(getInput().checked).toBe(true);
});
it('reverts the DOM when a toggle is rejected', () => {
render(() => (
<Harness
value={() => false}
processing={() => false}
reject={(setChecked) => setChecked(false)}
/>
));
// The browser flips the checkbox itself, so the rejection has to put it
// back even though the store value never changed.
fireEvent.change(getInput(), { target: { checked: true } });
expect(getInput().checked).toBe(false);
});
it('re-syncs with the store once the request finishes', () => {
const [value, setValue] = createSignal(true);
const [processing, setProcessing] = createSignal(false);
render(() => <Harness value={value} processing={processing} />);
expect(getInput().checked).toBe(true);
// A save is in flight — the switch keeps showing the user's intent.
setProcessing(true);
setValue(false);
expect(getInput().checked).toBe(true);
// Once it finishes, the store wins.
setProcessing(false);
expect(getInput().checked).toBe(false);
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, cleanup, waitFor } from '@solidjs/testing-library';
import { useIsDarkTheme } from 'panel/hooks/useTheme';
const Harness = () => {
const isDark = useIsDarkTheme();
return <div data-testid="harness" data-dark={String(isDark())} />;
};
/** Applies a theme the way `setUITheme` does, through the `<html>` attribute. */
const applyTheme = (theme: 'light' | 'dark') => {
document.documentElement.dataset.theme = theme;
};
const renderHarness = () => {
render(() => <Harness />);
return screen.getByTestId('harness');
};
describe('useIsDarkTheme', () => {
it('reports false while no theme is applied', () => {
expect(renderHarness()).toHaveAttribute('data-dark', 'false');
});
it('reports false for the light theme', () => {
applyTheme('light');
expect(renderHarness()).toHaveAttribute('data-dark', 'false');
});
it('reports true for the dark theme', () => {
applyTheme('dark');
expect(renderHarness()).toHaveAttribute('data-dark', 'true');
});
it('re-evaluates when the theme changes', async () => {
applyTheme('light');
const harness = renderHarness();
expect(harness).toHaveAttribute('data-dark', 'false');
applyTheme('dark');
// MutationObserver callbacks are queued, so the update is not synchronous.
await waitFor(() => expect(harness).toHaveAttribute('data-dark', 'true'));
});
it('stops observing the theme attribute on unmount', () => {
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect');
renderHarness();
cleanup();
expect(disconnect).toHaveBeenCalled();
disconnect.mockRestore();
});
});
@@ -0,0 +1,74 @@
import { render, waitFor } from '@solidjs/testing-library';
import { describe, it, expect } from 'vitest';
import { Form } from 'panel/login/Login/Form';
import { byViewportWidth, mockMatchMedia } from 'panel/__tests__/helpers/matchMedia';
/** Applies a theme the way `setUITheme` does, through the `<html>` attribute. */
const applyTheme = (theme: 'light' | 'dark') => {
document.documentElement.dataset.theme = theme;
};
/**
* Renders the form and returns the class list of the wrapper that carries the
* `onCard` styling around the field with `id`.
*/
const renderForm = () => {
const { container } = render(() => <Form onSubmit={() => {}} />);
return (id: string) => container.querySelector(`#${id}`)?.parentElement?.className ?? '';
};
describe('Login form fields', () => {
it('drops the card styling on mobile', () => {
mockMatchMedia(byViewportWidth(360));
applyTheme('dark');
const fieldClass = renderForm();
expect(fieldClass('username')).not.toContain('onCard');
expect(fieldClass('password')).not.toContain('onCard');
});
it('uses the card styling from the tablet breakpoint up in the dark theme', () => {
mockMatchMedia(byViewportWidth(1024));
applyTheme('dark');
const fieldClass = renderForm();
expect(fieldClass('username')).toContain('onCard');
expect(fieldClass('password')).toContain('onCard');
});
it('drops the card styling in the light theme', () => {
mockMatchMedia(byViewportWidth(1024));
applyTheme('light');
const fieldClass = renderForm();
expect(fieldClass('username')).not.toContain('onCard');
expect(fieldClass('password')).not.toContain('onCard');
});
it('follows the viewport when it is resized', () => {
const matchMedia = mockMatchMedia(byViewportWidth(360));
applyTheme('dark');
const fieldClass = renderForm();
matchMedia.set(byViewportWidth(1024));
expect(fieldClass('username')).toContain('onCard');
expect(fieldClass('password')).toContain('onCard');
});
it('follows the theme when it changes', async () => {
mockMatchMedia(byViewportWidth(1024));
applyTheme('light');
const fieldClass = renderForm();
expect(fieldClass('username')).not.toContain('onCard');
applyTheme('dark');
// MutationObserver callbacks are queued, so the update is not synchronous.
await waitFor(() => expect(fieldClass('username')).toContain('onCard'));
expect(fieldClass('password')).toContain('onCard');
});
});
@@ -142,3 +142,97 @@ describe('DetailModal actions footer', () => {
).not.toBeInTheDocument();
});
});
/**
* jsdom reports 0 for every scroll metric, so the scroll range of the modal's
* scroll area has to be injected. The properties live on `Element.prototype`,
* so shadowing them on `HTMLElement.prototype` and deleting the shadow restores
* the originals.
*/
const installScrollMetrics = ({
scrollTop = 0,
scrollHeight = 0,
clientHeight = 0,
}: { scrollTop?: number; scrollHeight?: number; clientHeight?: number } = {}) => {
const metrics = { scrollTop, scrollHeight, clientHeight };
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => metrics.scrollTop,
set: (value: number) => {
metrics.scrollTop = value;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => metrics.scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get: () => metrics.clientHeight,
});
};
const restoreScrollMetrics = () => {
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTop');
Reflect.deleteProperty(HTMLElement.prototype, 'scrollHeight');
Reflect.deleteProperty(HTMLElement.prototype, 'clientHeight');
};
describe('DetailModal scroll edges', () => {
beforeEach(() => {
vi.spyOn(intl, 'getMessage').mockImplementation((key) => key);
});
afterEach(() => {
restoreScrollMetrics();
vi.restoreAllMocks();
});
const renderModal = () => {
render(() => <DetailModal {...defaultProps} entry={makeEntry()} />);
return screen.getByTestId('query-log-detail-scroll-area');
};
it('flags both edges while the content overflows in both directions', () => {
installScrollMetrics({ scrollTop: 100, scrollHeight: 600, clientHeight: 200 });
const scrollArea = renderModal();
expect(scrollArea).toHaveAttribute('data-can-scroll-up', 'true');
expect(scrollArea).toHaveAttribute('data-can-scroll-down', 'true');
});
it('flags no edges when the content fits', () => {
installScrollMetrics({ scrollHeight: 200, clientHeight: 200 });
const scrollArea = renderModal();
expect(scrollArea).toHaveAttribute('data-can-scroll-up', 'false');
expect(scrollArea).toHaveAttribute('data-can-scroll-down', 'false');
});
it('drops the bottom edge once scrolled all the way down', () => {
installScrollMetrics({ scrollTop: 400, scrollHeight: 600, clientHeight: 200 });
const scrollArea = renderModal();
expect(scrollArea).toHaveAttribute('data-can-scroll-up', 'true');
expect(scrollArea).toHaveAttribute('data-can-scroll-down', 'false');
});
it('updates the edges when the scroll area is scrolled', () => {
installScrollMetrics({ scrollTop: 0, scrollHeight: 600, clientHeight: 200 });
const scrollArea = renderModal();
expect(scrollArea).toHaveAttribute('data-can-scroll-up', 'false');
expect(scrollArea).toHaveAttribute('data-can-scroll-down', 'true');
scrollArea.scrollTop = 400;
fireEvent.scroll(scrollArea);
expect(scrollArea).toHaveAttribute('data-can-scroll-up', 'true');
expect(scrollArea).toHaveAttribute('data-can-scroll-down', 'false');
});
});
+40
View File
@@ -299,3 +299,43 @@ describe('Select — auto-searchable threshold', () => {
expect(document.querySelector('[data-scope="select"]')).not.toBeInTheDocument();
});
});
describe('Select — onCard', () => {
it('adds the on-card class to the root when set', () => {
render(() => (
<Select options={OPTIONS} value={OPTIONS[0]} onChange={() => {}} onCard />
));
expect(document.querySelector('.solid-select')?.className).toContain(
'solid-select--on-card',
);
});
it('does not add the on-card class by default', () => {
render(() => <Select options={OPTIONS} value={OPTIONS[0]} onChange={() => {}} />);
expect(document.querySelector('.solid-select')?.className).not.toContain(
'solid-select--on-card',
);
});
});
describe('Select — menuOnCard', () => {
it('adds the menu-on-card class to the root when set', () => {
render(() => (
<Select options={OPTIONS} value={OPTIONS[0]} onChange={() => {}} menuOnCard />
));
expect(document.querySelector('.solid-select')?.className).toContain(
'solid-select--menu-on-card',
);
});
it('does not add the menu-on-card class by default', () => {
render(() => <Select options={OPTIONS} value={OPTIONS[0]} onChange={() => {}} />);
expect(document.querySelector('.solid-select')?.className).not.toContain(
'solid-select--menu-on-card',
);
});
});
+5 -1
View File
@@ -4,13 +4,17 @@ import { cleanup } from '@solidjs/testing-library';
afterEach(() => cleanup());
// The theme lives on `<html>` and would otherwise leak into the next test.
afterEach(() => document.documentElement.removeAttribute('data-theme'));
// Mock window.scrollTo for router navigation (jsdom doesn't implement it).
Object.defineProperty(window, 'scrollTo', {
writable: true,
value: () => {},
});
// Mock window.matchMedia for components that use useIsMobile
// Mock window.matchMedia for components that use the responsive hooks from
// `panel/hooks/useMediaQuery`.
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string): MediaQueryList =>
@@ -84,4 +84,13 @@ describe('StaticLeaseModal', () => {
expect(screen.getByText(copyInDom('dhcp_mac_address_already_added'))).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it('styles the dialog fields for the card surface', () => {
renderModal();
// The dialog paints `--default-cards-background`, so its fields must
// opt into the on-card background.
expect(getInput('static_lease_mac').parentElement?.className).toContain('onCard');
expect(getInput('static_lease_ip').parentElement?.className).toContain('onCard');
});
});
@@ -0,0 +1,58 @@
import { type JSX } from 'solid-js';
import { render, screen } from '@solidjs/testing-library';
import { HashRouter, Route } from '@solidjs/router';
import { describe, it, expect } from 'vitest';
import { StatMobileCard } from 'panel/components/Stats/blocks/StatMobileCard';
import { copyInDom } from 'panel/__tests__/helpers/copy';
const renderWithRouter = (ui: () => JSX.Element) =>
render(() => (
<HashRouter>
<Route path="/" component={ui} />
</HashRouter>
));
const items = [{ label: 'Queries', value: '5' }];
describe('StatMobileCard', () => {
it('renders the whole card as a query log link when cardLink is set', () => {
renderWithRouter(() => (
<StatMobileCard
title="a.org"
cardLink={{ to: 'QueryLog', query: { search: '"a.org"' } }}
items={items}
/>
));
const card = screen.getByTestId('stats-mobile-card');
expect(card.tagName).toBe('A');
expect(card.getAttribute('href')).toContain('/logs');
expect(card.getAttribute('href')).toContain('a.org');
expect(card.querySelectorAll('a').length).toBe(0);
});
it('keeps the card a plain container without cardLink', () => {
renderWithRouter(() => <StatMobileCard title="a.org" items={items} />);
const card = screen.getByTestId('stats-mobile-card');
expect(card.tagName).toBe('DIV');
expect(card.getAttribute('href')).toBeNull();
});
it('does not follow the card link when an action is clicked', () => {
renderWithRouter(() => (
<StatMobileCard
title="a.org"
cardLink={{ to: 'QueryLog', query: { search: '"a.org"' } }}
items={items}
actions={<button type="button">{copyInDom('block_client')}</button>}
/>
));
const click = new MouseEvent('click', { bubbles: true, cancelable: true });
screen.getByRole('button', { name: copyInDom('block_client') }).dispatchEvent(click);
expect(click.defaultPrevented).toBe(true);
});
});
@@ -3,10 +3,12 @@ import { HashRouter, Route } from '@solidjs/router';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { StatsPage } from 'panel/components/Stats/StatsPage';
import { DEFAULT_PAGE_SIZE } from 'panel/common/ui/Table/Table';
import type { TableColumn } from 'panel/common/ui/Table';
import { LocalStorageHelper } from 'panel/helpers/localStorageHelper';
import type { IOption } from 'panel/lib/helpers/utils';
import { copy, copyInDom } from 'panel/__tests__/helpers/copy';
import { mockMatchMedia } from 'panel/__tests__/helpers/matchMedia';
type Row = { name: string; count: number };
@@ -33,23 +35,6 @@ const mobileSortOptions: IOption<string>[] = [
{ value: 'count:asc', label: 'Count asc' },
];
const mockMatchMedia = (matches: boolean) => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) =>
({
matches,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList,
});
};
const renderPage = (overrides: Partial<Parameters<typeof StatsPage<Row>>[0]> = {}) =>
render(() => (
<HashRouter>
@@ -142,6 +127,14 @@ describe('StatsPage', () => {
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it('labels the refresh control with aria-label instead of a native title', () => {
const { container } = renderPage();
const refresh = screen.getByRole('button', { name: copyInDom('refresh_btn') });
expect(refresh).not.toHaveAttribute('title');
expect(container.querySelectorAll('[title]').length).toBe(0);
});
it('shows the empty state text when nothing matches', () => {
renderPage();
fireEvent.input(screen.getByTestId('stats-search-input'), {
@@ -233,4 +226,56 @@ describe('StatsPage', () => {
expect(screen.queryByTestId('stats-empty-state')).not.toBeInTheDocument();
expect(screen.queryByTestId('stats-mobile-list')).not.toBeInTheDocument();
});
it('renders every row as a link when rowLink is provided', () => {
const { container } = renderPage({
rowLink: (row) => ({ to: 'QueryLog', query: { search: `"${row.name}"` } }),
});
const rows = Array.from(container.querySelectorAll('[class*="tableRow"]'));
expect(rows.length).toBe(3);
rows.forEach((row) => {
expect(row.tagName).toBe('A');
expect(row.getAttribute('href')).toContain('/logs');
});
expect(container.querySelectorAll('a a').length).toBe(0);
});
it('keeps rows as plain containers when rowLink is not provided', () => {
const { container } = renderPage();
const rows = Array.from(container.querySelectorAll('[class*="tableRow"]'));
rows.forEach((row) => expect(row.tagName).toBe('DIV'));
});
});
describe('StatsPage — the pagination footer', () => {
// The rows-per-page select lives in the footer, so the footer is only
// worth showing once the list needs more than one page.
const makeRows = (count: number): Row[] =>
Array.from({ length: count }, (_, index) => ({
name: `domain-${index}.org`,
count: index,
}));
it('hides the rows-per-page select when the list fits one page (desktop)', () => {
mockMatchMedia(true);
renderPage({ rows: makeRows(DEFAULT_PAGE_SIZE) });
expect(screen.queryByTestId('pagination-page-size-select')).toBeNull();
});
it('hides the rows-per-page select when the list fits one page (mobile)', () => {
mockMatchMedia(false);
renderPage({ rows: makeRows(DEFAULT_PAGE_SIZE) });
expect(screen.queryByTestId('pagination-page-size-select')).toBeNull();
});
it('shows the rows-per-page select once the list needs a second page', () => {
mockMatchMedia(true);
renderPage({ rows: makeRows(DEFAULT_PAGE_SIZE + 1) });
expect(screen.getByTestId('pagination-page-size-select')).toBeInTheDocument();
});
});
+67 -1
View File
@@ -2,24 +2,64 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
const mocks = vi.hoisted(() => ({
stats: vi.fn(),
getStatsConfig: vi.fn(),
putStatsConfig: vi.fn(),
statsReset: vi.fn(),
clientsSearch: vi.fn(),
addErrorToast: vi.fn(),
addSuccessToast: vi.fn(),
}));
vi.mock('panel/api/generated', () => ({
stats: mocks.stats,
getStatsConfig: mocks.getStatsConfig,
putStatsConfig: mocks.putStatsConfig,
statsReset: mocks.statsReset,
clientsSearch: mocks.clientsSearch,
}));
vi.mock('panel/stores/toasts', () => ({
addErrorToast: mocks.addErrorToast,
addSuccessToast: mocks.addSuccessToast,
}));
vi.mock('panel/common/intl', () => ({
default: { getMessage: vi.fn() },
}));
import { getStats, statsState } from 'panel/stores/stats';
import { DAY } from 'panel/helpers/constants';
import { getStats, getStatsConfig, statsState } from 'panel/stores/stats';
describe('getStats', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.clientsSearch.mockResolvedValue([]);
mocks.stats.mockResolvedValue({});
});
it('clamps the requested period by the configured stats interval', async () => {
await getStats(DAY * 30);
expect(mocks.stats).toHaveBeenCalledWith({ recent: DAY });
// A smaller period is passed through untouched.
await getStats(6 * 60 * 60 * 1000);
expect(mocks.stats).toHaveBeenCalledWith({ recent: 6 * 60 * 60 * 1000 });
});
it('calls the API without params when no period is given', async () => {
await getStats();
expect(mocks.stats).toHaveBeenCalledWith(undefined);
});
it('marks statsAttempted after a successful stats request', async () => {
await getStats();
expect(statsState.statsAttempted).toBe(true);
});
it('marks statsAttempted after a failed stats request', async () => {
mocks.stats.mockRejectedValue(new Error('network'));
await getStats();
expect(statsState.statsAttempted).toBe(true);
expect(mocks.addErrorToast).toHaveBeenCalled();
});
it('enriches top clients and stores normalizedTopClients', async () => {
@@ -70,3 +110,29 @@ describe('getStats', () => {
]);
});
});
describe('getStatsConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('marks configAttempted but not configLoaded on failure', async () => {
mocks.getStatsConfig.mockRejectedValue(new Error('network'));
await getStatsConfig();
expect(statsState.configAttempted).toBe(true);
expect(statsState.configLoaded).toBe(false);
expect(mocks.addErrorToast).toHaveBeenCalled();
});
it('marks configLoaded and configAttempted on success', async () => {
mocks.getStatsConfig.mockResolvedValue({ interval: DAY * 30, enabled: true });
await getStatsConfig();
expect(statsState.configLoaded).toBe(true);
expect(statsState.configAttempted).toBe(true);
expect(statsState.interval).toBe(DAY * 30);
});
});
@@ -41,4 +41,26 @@ describe('toasts store', () => {
const last = toastsState.notices[toastsState.notices.length - 1];
expect(last.message).toBe('config_successfully_saved');
});
it('collapses a repeated identical error into one notice', () => {
addErrorToast({ error: 'boom' });
addErrorToast({ error: 'boom' });
expect(toastsState.notices.filter((n) => n.message === 'boom')).toHaveLength(1);
});
it('keeps notices with different messages apart', () => {
addErrorToast({ error: 'boom' });
addErrorToast({ error: 'bang' });
expect(toastsState.notices).toHaveLength(2);
});
it('does not collapse an error that carries an action', () => {
const action = { text: 'retry', callback: () => {} };
addErrorToast({ error: 'boom', action });
addErrorToast({ error: 'boom' });
expect(toastsState.notices).toHaveLength(2);
});
});
+5 -28
View File
@@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@solidjs/testing-library';
import { Tooltip } from 'panel/common/ui/Tooltip';
import { copy } from 'panel/__tests__/helpers/copy';
import { mockMatchMedia } from 'panel/__tests__/helpers/matchMedia';
// jsdom lacks ResizeObserver, which floating-ui (used by Zag positioning) needs.
beforeAll(() => {
@@ -15,35 +16,11 @@ beforeAll(() => {
}
});
// Restore real matchMedia between test suites. The global mock from
// src/__tests__/setup.ts returns matches: false for every query (desktop),
// which is what most tests need. Touch tests in this file replace it
// temporarily so that (hover: none) returns true.
const realMatchMedia = window.matchMedia;
function mockTouchDevice() {
window.matchMedia = (query: string): MediaQueryList => {
const matches = query === '(hover: none)';
return {
matches,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
} as MediaQueryList;
};
}
function mockDesktop() {
window.matchMedia = realMatchMedia;
}
describe('Tooltip', () => {
beforeEach(() => {
mockDesktop();
// Matches the global default from src/__tests__/setup.ts: no query
// matches, so the tooltip behaves as on a desktop.
mockMatchMedia(false);
});
afterEach(() => {
@@ -176,7 +153,7 @@ describe('Tooltip', () => {
describe('touch device', () => {
beforeEach(() => {
mockTouchDevice();
mockMatchMedia((query) => query === '(hover: none)');
});
it('opens on click (tap)', async () => {
@@ -17,15 +17,17 @@
border-color: var(--pressed-link);
}
&:hover,
&:hover .input {
background-color: var(--default-input-background);
}
@media (hover: hover) {
&:hover,
&:hover .input {
background-color: var(--default-input-background);
}
&:not(.disabled):hover .input:-webkit-autofill,
&:not(.disabled):hover .input:-webkit-autofill:hover,
&:not(.disabled):hover .input:-webkit-autofill:focus {
box-shadow: 0 0 0 100px var(--default-input-background) inset;
&:not(.disabled):hover .input:-webkit-autofill,
&:not(.disabled):hover .input:-webkit-autofill:hover,
&:not(.disabled):hover .input:-webkit-autofill:focus {
box-shadow: 0 0 0 100px var(--default-input-background) inset;
}
}
&.disabled {
@@ -70,6 +72,33 @@
}
}
&.onCard:not(.disabled) {
background-color: var(--page-background-additional);
.input {
background-color: var(--page-background-additional);
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
box-shadow: 0 0 0 100px var(--page-background-additional) inset;
}
}
@media (hover: hover) {
&:hover,
&:hover .input {
background-color: var(--page-background-additional-hovered);
}
&:hover .input:-webkit-autofill,
&:hover .input:-webkit-autofill:hover,
&:hover .input:-webkit-autofill:focus {
box-shadow: 0 0 0 100px var(--page-background-additional-hovered) inset;
}
}
}
.input {
color: var(--default-main-text);
font-size: 16px;
@@ -18,6 +18,7 @@ type Props = Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'size' | 'onChange'
suffixIcon?: JSX.Element;
borderless?: boolean;
invalid?: boolean;
onCard?: boolean;
maxLength?: number;
error?: boolean;
errorMessage?: string;
@@ -119,6 +120,7 @@ export const Input = (props: Props) => {
[s.prefix]: props.prefixIcon,
[s.suffix]: hasActions(),
[s.invalid]: props.invalid,
[s.onCard]: props.onCard,
[s.focused]: focused(),
[s.disabled]: props.disabled,
[s.error]: props.error || !!computedErrorMessage(),
@@ -13,8 +13,10 @@
border-radius: 8px;
transition: background-color var(--t2);
&:hover:not(:has(input:disabled)) {
background-color: var(--page-background-additional);
@media (hover: hover) {
&:hover:not(:has(input:disabled)) {
background-color: var(--page-background-additional-hovered);
}
}
&.center {
@@ -75,11 +75,16 @@
transition: background-color var(--t2);
&:not([data-disabled]) {
&:hover,
&:has(:focus-visible) {
background-color: var(--hovered-secondary-button);
}
@media (hover: hover) {
&:hover {
background-color: var(--hovered-secondary-button);
}
}
&:active {
background-color: var(--pressed-secondary-button);
}
@@ -187,11 +192,18 @@
color: var(--default-main-text);
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
max-height: var(--available-height, none);
box-shadow: 8px 8px 24px 0 rgba(0, 0, 0, 0.1);
background-color: var(--default-dropdown-menu-background);
z-index: 3;
border-radius: 8px;
&[hidden] {
display: none;
}
@media (min-width: 1024px) {
left: initial;
right: initial;
@@ -221,8 +233,10 @@
display: flex;
align-items: stretch;
&:hover {
background-color: var(--default-input-background);
@media (hover: hover) {
&:hover {
background-color: var(--default-input-background);
}
}
&:focus-within {
@@ -245,6 +259,37 @@
}
}
&.solid-select--on-card {
[data-scope='select'][data-part='control'],
[data-scope='combobox'][data-part='control'] {
background-color: var(--page-background-additional);
@media (hover: hover) {
&:not([data-disabled]):hover {
background-color: var(--page-background-additional-hovered);
}
}
}
}
&.solid-select--menu-on-card {
[data-scope='select'][data-part='content'],
[data-scope='combobox'][data-part='content'] {
background-color: var(--default-page-background);
}
[data-scope='select'][data-part='item'],
[data-scope='combobox'][data-part='item'] {
&[data-highlighted] {
background-color: var(--hovered-page-background);
}
&[data-state='checked'] {
background-color: var(--pressed-page-background);
}
}
}
[data-scope='select'][data-part='trigger'] {
flex: 1;
display: flex;
@@ -411,6 +456,8 @@
overflow-y: auto;
overflow-x: hidden;
max-height: 340px;
flex: 0 1 auto;
min-height: 0;
&::-webkit-scrollbar {
width: 6px;
@@ -49,6 +49,8 @@ export const Select = <
{ 'solid-select--menu-big': props.menuSize === 'big' },
{ 'solid-select--menu-right': props.menuPosition === 'right' },
{ 'solid-select--borderless': props.borderless },
{ 'solid-select--on-card': props.onCard },
{ 'solid-select--menu-on-card': props.menuOnCard },
{ 'solid-select--adaptive-height': props.adaptiveHeight },
{ 'solid-select--height-extra-small': props.height === 'extra-small' },
{ 'solid-select--height-small': props.height === 'small' },
@@ -34,6 +34,10 @@ export interface SelectProps<
id?: string;
inputId?: string;
borderless?: boolean;
/** Fills the control with `--page-background-additional` for cards-background surfaces. */
onCard?: boolean;
/** Switches the dropdown menu to the `page-background` tokens on cards-background surfaces. */
menuOnCard?: boolean;
adaptiveHeight?: boolean;
lazyList?: boolean;
closeMenuOnSelect?: boolean;
@@ -32,8 +32,10 @@
background-color: var(--fills-switch-off-default);
transition: background-color var(--t2);
&:hover {
background-color: var(--fills-switch-off-hovered);
@media (hover: hover) {
&:hover {
background-color: var(--fills-switch-off-hovered);
}
}
&:before {
@@ -58,8 +60,10 @@
.input:checked + .handler {
background-color: var(--fills-switch-on-default);
&:hover {
background-color: var(--fills-switch-on-hovered);
@media (hover: hover) {
&:hover {
background-color: var(--fills-switch-on-hovered);
}
}
&:before {
@@ -75,8 +79,10 @@
cursor: default;
background-color: var(--fills-switch-off-disabled);
&:hover {
background-color: var(--fills-switch-off-disabled);
@media (hover: hover) {
&:hover {
background-color: var(--fills-switch-off-disabled);
}
}
&:before {
@@ -87,8 +93,10 @@
.input:checked:disabled + .handler {
background-color: var(--fills-switch-on-disabled);
&:hover {
background-color: var(--fills-switch-on-disabled);
@media (hover: hover) {
&:hover {
background-color: var(--fills-switch-on-disabled);
}
}
&:before {
@@ -21,6 +21,7 @@ type Props = Omit<
label?: JSX.Element;
size?: 'small' | 'medium' | 'large' | 'compact';
errorMessage?: string;
onCard?: boolean;
highlightComments?: boolean;
commentPrefixes?: readonly CommentLineToken[];
ref?: HTMLTextAreaElement | ((el: HTMLTextAreaElement) => void);
@@ -120,6 +121,7 @@ export const Textarea = (props: Props) => {
props.size && s[props.size],
{ [s.error]: !!props.errorMessage },
{ [s.clearable]: showClearButton() },
{ [s.onCard]: props.onCard },
props.class,
)}
id={props.id}
@@ -143,6 +145,7 @@ export const Textarea = (props: Props) => {
<div
class={cn(s.scrollArea, props.size && s[props.size], {
[s.error]: !!props.errorMessage,
[s.onCard]: props.onCard,
})}
style={scrollAreaStyle()}
>
@@ -31,8 +31,10 @@
border-color: var(--pressed-link);
}
&:hover {
background-color: var(--default-input-background);
@media (hover: hover) {
&:hover {
background-color: var(--default-input-background);
}
}
&::placeholder {
@@ -67,6 +69,16 @@
&.compact {
min-height: 116px;
}
&.onCard:not(:disabled) {
background-color: var(--page-background-additional);
@media (hover: hover) {
&:hover {
background-color: var(--page-background-additional-hovered);
}
}
}
}
/* Leaves room for the clear button (16px inset + 24px icon + 16px gap). */
@@ -114,8 +126,10 @@
border-color: var(--pressed-link);
}
&:hover {
background-color: var(--default-input-background);
@media (hover: hover) {
&:hover {
background-color: var(--default-input-background);
}
}
&.error {
@@ -133,6 +147,16 @@
&.compact {
min-height: 116px;
}
&.onCard:not(:has(:disabled)) {
background-color: var(--page-background-additional);
@media (hover: hover) {
&:hover {
background-color: var(--page-background-additional-hovered);
}
}
}
}
.contentWrapper {
@@ -8,6 +8,7 @@
--page-background-additional-pressed: var(--gray-70);
/* Cards background */
--default-cards-background: var(--gray-80);
--default-cards-box-shadow: none;
/* Popup background */
--default-popup-background: var(--gray-90);
/* Footer background */
@@ -8,6 +8,7 @@
--page-background-additional-pressed: var(--gray-30);
/* Cards background */
--default-cards-background: var(--gray-0);
--default-cards-box-shadow: 8px 8px 24px 0 rgba(0, 0, 0, 0.1);
/* Popup background */
--default-popup-background: var(--gray-0);
/* Footer background */
@@ -0,0 +1,51 @@
@media screen and (max-width: 767px) {
input,
select,
textarea {
font-size: 1rem;
}
}
.wrapper {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--default-page-background);
}
.content {
display: flex;
flex: 1 0 auto;
align-items: center;
justify-content: center;
box-sizing: border-box;
@media screen and (min-width: 768px) {
padding: 40px 16px;
}
}
.card {
display: flex;
flex-direction: column;
width: 100%;
max-width: 632px;
padding-bottom: 24px;
box-sizing: border-box;
@media screen and (min-width: 768px) {
padding: 0 24px;
background-color: var(--default-cards-background);
border-radius: 16px;
}
}
.title {
margin: 0;
padding: 40px 16px 0;
color: var(--default-main-text);
@media screen and (min-width: 768px) {
padding: 48px 16px 16px;
}
}
@@ -0,0 +1,33 @@
import { type JSX } from 'solid-js';
import cn from 'clsx';
import { Icons } from 'panel/common/ui/Icons';
import { PublicHeader } from 'panel/common/ui/PublicHeader';
import { Toasts } from 'panel/components/Toasts';
import theme from 'panel/lib/theme';
import styles from './AuthLayout.module.pcss';
type Props = {
title: string;
children: JSX.Element;
};
export const AuthLayout = (props: Props) => (
<div class={styles.wrapper}>
<PublicHeader variant="auth" dropdownPosition="bottomRight" useLocalLanguage={true} />
<main class={styles.content}>
<div class={styles.card}>
<h1 class={cn(styles.title, theme.title.h4, theme.title.h3_tablet)}>
{props.title}
</h1>
{props.children}
</div>
</main>
<Toasts />
<Icons />
</div>
);
@@ -0,0 +1 @@
export { AuthLayout } from './AuthLayout';
+8 -3
View File
@@ -4,7 +4,7 @@ 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 { RoutePath, TLS_WIZARD_QUERY_KEY, linkPathBuilder } from 'panel/components/Routes/Paths';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
@@ -74,6 +74,11 @@ export const Banners = (props: Props) => {
return active;
});
const openTlsWizard = () =>
navigate(
linkPathBuilder(RoutePath.Encryption, undefined, { [TLS_WIZARD_QUERY_KEY]: 'true' }),
);
const announcementLinkHandler = (announcementUrl: string) => (text: string) => (
<a href={announcementUrl} class={theme.link.link} target="_blank" rel="noopener noreferrer">
{text}
@@ -97,7 +102,7 @@ export const Banners = (props: Props) => {
variant="secondary"
size="very-small"
compact
onClick={() => navigate(Paths.Encryption)}
onClick={openTlsWizard}
class={s.actionButton}
>
{intl.getMessage('update_button')}
@@ -118,7 +123,7 @@ export const Banners = (props: Props) => {
variant="secondary"
size="very-small"
compact
onClick={() => navigate(Paths.Encryption)}
onClick={openTlsWizard}
class={s.actionButton}
>
{intl.getMessage('update_button')}
@@ -2,6 +2,7 @@ import { For } from 'solid-js';
import cn from 'clsx';
import { Icon } from 'panel/common/ui/Icon';
import { Link } from 'panel/common/ui/Link';
import { TruncatedText } from 'panel/common/ui/TruncatedText';
import { type RoutePathKey } from 'panel/components/Routes/Paths';
import theme from 'panel/lib/theme';
@@ -26,7 +27,6 @@ export const Breadcrumbs = (props: Props) => (
<div class={s.link}>
<Link
to={path}
title={title}
class={cn(
theme.link.link,
theme.link.noDecoration,
@@ -41,8 +41,6 @@ export const Breadcrumbs = (props: Props) => (
</div>
)}
</For>
<div class={cn(theme.common.textOverflow, s.current)} title={props.currentTitle}>
{props.currentTitle}
</div>
<TruncatedText text={props.currentTitle} class={s.current} />
</div>
);
@@ -97,6 +97,23 @@
}
}
.secondary.onCard {
background: var(--default-secondary-card-button);
&:hover,
&:focus-visible {
background: var(--hovered-secondary-card-button);
}
&:active {
background: var(--pressed-secondary-card-button);
}
&:disabled {
background-color: var(--disabled-secondary-card-button);
}
}
.ghost {
color: var(--default-main-text);
background: none;
@@ -14,6 +14,7 @@ export type ButtonVariant =
export type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & {
size?: 'very-small' | 'small' | 'medium' | 'big';
variant?: ButtonVariant;
onCard?: boolean;
leftAddon?: JSX.Element;
rightAddon?: JSX.Element;
compact?: boolean;
@@ -28,6 +29,7 @@ export const Button = (props: ButtonProps) => {
'className',
'children',
'disabled',
'onCard',
'leftAddon',
'rightAddon',
'compact',
@@ -41,6 +43,7 @@ export const Button = (props: ButtonProps) => {
class={cn(
s.button,
s[local.variant || 'primary'],
{ [s.onCard]: local.onCard },
{
[s.height_xs]: local.size === 'very-small',
[s.height_s]: local.size === 'small',
@@ -0,0 +1,29 @@
.item {
display: flex;
align-items: center;
width: 100%;
min-height: 56px;
padding: 16px;
border: 0;
background: none;
appearance: none;
font-family: inherit;
text-align: left;
color: var(--default-link);
cursor: pointer;
transition: background-color var(--t2);
&:hover,
&:focus-visible {
background-color: var(--hovered-dropdown-menu-background);
outline: 0;
}
&:active {
background-color: var(--pressed-dropdown-menu-background);
}
}
.itemDanger {
color: var(--default-error-link);
}
@@ -0,0 +1,37 @@
import { createMemo } from 'solid-js';
import cn from 'clsx';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import s from './ClientBlockMenuItem.module.pcss';
import type { ClientBlockAction } from './ClientBlockConfirm';
type Props = {
action: ClientBlockAction;
onClick: (action: ClientBlockAction) => void;
class?: string;
};
/**
* The block/unblock action for one client, shared by the Dashboard card and
* the Top clients page. Rendered as a `<button>` so it is keyboard-operable
* both inline on mobile and inside the desktop dropdown.
*/
export const ClientBlockMenuItem = (props: Props) => {
const isBlock = createMemo(() => props.action === 'block');
return (
<button
type="button"
class={cn(theme.text.t2, theme.text.condenced, s.item, props.class, {
[s.itemDanger]: isBlock(),
})}
data-testid={isBlock() ? 'client-block-menu-item' : 'client-unblock-menu-item'}
onClick={() => props.onClick(props.action)}
>
{isBlock() ? intl.getMessage('block_client') : intl.getMessage('unblock_client')}
</button>
);
};
@@ -4,4 +4,5 @@ export {
CLIENT_BLOCK_CONFIRM_SUBMIT_TEST_ID,
initialClientBlockConfirmState,
} from './ClientBlockConfirm';
export { ClientBlockMenuItem } from './ClientBlockMenuItem';
export type { ClientBlockAction, ClientBlockConfirmState } from './ClientBlockConfirm';
@@ -2,7 +2,7 @@ import { type JSX, Show } from 'solid-js';
import cn from 'clsx';
import { Dialog } from 'panel/common/ui/Dialog';
import { Button } from 'panel/common/ui/Button';
import { Button, type ButtonVariant } from 'panel/common/ui/Button';
import intl from 'panel/common/intl';
import s from './ConfigDialog.module.pcss';
@@ -20,6 +20,7 @@ type Props = {
description?: JSX.Element;
hideSubmit?: boolean;
buttonText?: string;
buttonVariant?: ButtonVariant;
};
export const ConfigDialog = (props: Props) => {
@@ -41,7 +42,7 @@ export const ConfigDialog = (props: Props) => {
{props.footer}
<Show when={!props.hideSubmit}>
<Button
variant="primary"
variant={props.buttonVariant ?? 'primary'}
class={s.saveButton}
disabled={isDisabled()}
data-testid="config-dialog-save"
@@ -59,6 +59,7 @@ export const ConfirmDialog = (props: Props) => (
size="small"
onClick={props.onClose}
class={theme.dialog.button}
onCard
>
{props.cancelText}
</Button>
@@ -15,8 +15,10 @@
}
.container {
&:hover {
background: var(--page-background-additional-hovered);
@media (hover: hover) {
&:hover {
background: var(--page-background-additional-hovered);
}
}
&:focus {
@@ -0,0 +1,30 @@
.dangerLink {
display: flex;
align-items: center;
width: 100%;
min-width: 128px;
min-height: 24px;
margin-top: 32px;
padding: 8px 16px;
border: none;
background: transparent;
text-align: left;
color: var(--default-error-link);
cursor: pointer;
transition: color var(--t2);
&:hover,
&:focus-visible {
color: var(--hovered-danger-button);
text-decoration: underline;
}
&:active {
color: var(--pressed-danger-button);
}
&:disabled {
cursor: not-allowed;
color: var(--disabled-main-button);
}
}
@@ -0,0 +1,26 @@
import { type JSX } from 'solid-js';
import cn from 'clsx';
import theme from 'panel/lib/theme';
import s from './DangerLink.module.pcss';
type Props = {
children: JSX.Element;
onClick: () => void;
class?: string;
disabled?: boolean;
testId?: string;
};
export const DangerLink = (props: Props) => (
<button
type="button"
data-testid={props.testId}
disabled={props.disabled}
onClick={() => props.onClick()}
class={cn(theme.text.t2, theme.text.condenced, s.dangerLink, props.class)}
>
{props.children}
</button>
);
@@ -0,0 +1 @@
export { DangerLink } from './DangerLink';
+1 -1
View File
@@ -43,7 +43,7 @@
margin: 0 auto;
max-width: 720px;
max-height: calc(100vh - 32px);
background-color: var(--default-page-background);
background-color: var(--default-cards-background);
border: none;
border-radius: 16px;
background-clip: border-box;
@@ -10,7 +10,6 @@
.selectOverlay {
width: 100%;
max-width: calc(100% - 48px);
-webkit-tap-highlight-color: transparent;
@media (min-width: 768px) {
max-width: 528px;
@@ -7,7 +7,7 @@
width: 100%;
min-height: 146px;
padding: 16px;
border: 1px solid transparent;
border: 1px dashed var(--default-item-divider);
border-radius: 16px;
background-color: var(--page-background-additional);
color: var(--default-description-text);
@@ -17,7 +17,7 @@
&.dragOver {
background-color: var(--fills-backgrounds-recent-activity);
border: 1px dashed var(--default-product-icon);
border-color: var(--default-product-icon);
color: var(--default-main-text);
}
}
+26 -26
View File
@@ -33,7 +33,7 @@ const RouterLayout = (props: PlatformLayoutProps) => (
<div class={s.guideText}>
<div class={s.guideParagraph}>{intl.getMessage('setup_devices_router_desc_1')}</div>
<ol class={cn({ [s.strongNumbers]: true })}>
<li class={s.guideItem}>
<li>
<strong class={s.guideItemTitle}>
{intl.getMessage('setup_devices_router_list_1_title')}
</strong>
@@ -42,7 +42,7 @@ const RouterLayout = (props: PlatformLayoutProps) => (
code2: () => <CopiedText text="http://192.168.1.1" />,
})}
</li>
<li class={s.guideItem}>
<li>
<strong class={s.guideItemTitle}>
{intl.getMessage('setup_devices_router_list_2_title')}
</strong>
@@ -68,14 +68,14 @@ const RouterLayout = (props: PlatformLayoutProps) => (
const WindowsLayout = () => (
<div title="Windows">
<div class={s.title}>{intl.getMessage('setup_devices_windows_title')}</div>
<div class={s.text}>
<div class={s.guideText}>
<ol class={s.guideList}>
<li class={s.guideItem}>{intl.getMessage('setup_devices_windows_list_1')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_windows_list_2')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_windows_list_3')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_windows_list_4')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_windows_list_5')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_windows_list_6')}</li>
<li>{intl.getMessage('setup_devices_windows_list_1')}</li>
<li>{intl.getMessage('setup_devices_windows_list_2')}</li>
<li>{intl.getMessage('setup_devices_windows_list_3')}</li>
<li>{intl.getMessage('setup_devices_windows_list_4')}</li>
<li>{intl.getMessage('setup_devices_windows_list_5')}</li>
<li>{intl.getMessage('setup_devices_windows_list_6')}</li>
</ol>
</div>
</div>
@@ -84,11 +84,11 @@ const WindowsLayout = () => (
const MacOSLayout = () => (
<div title="macOS">
<div class={s.title}>macOS</div>
<div class={s.text}>
<div class={s.guideText}>
<ol class={s.guideList}>
<li class={s.guideItem}>{intl.getMessage('setup_devices_macos_list_1')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_macos_list_2')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_macos_list_3')}</li>
<li>{intl.getMessage('setup_devices_macos_list_1')}</li>
<li>{intl.getMessage('setup_devices_macos_list_2')}</li>
<li>{intl.getMessage('setup_devices_macos_list_3')}</li>
</ol>
</div>
</div>
@@ -97,12 +97,12 @@ const MacOSLayout = () => (
const AndroidLayout = () => (
<div title="Android">
<div class={s.title}>Android</div>
<div class={s.text}>
<div class={s.guideText}>
<ol class={s.guideList}>
<li class={s.guideItem}>{intl.getMessage('setup_devices_android_list_1')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_android_list_2')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_android_list_3')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_android_list_4')}</li>
<li>{intl.getMessage('setup_devices_android_list_1')}</li>
<li>{intl.getMessage('setup_devices_android_list_2')}</li>
<li>{intl.getMessage('setup_devices_android_list_3')}</li>
<li>{intl.getMessage('setup_devices_android_list_4')}</li>
</ol>
</div>
</div>
@@ -111,19 +111,19 @@ const AndroidLayout = () => (
const IOSLayout = () => (
<div title="iOS">
<div class={s.title}>iOS</div>
<div class={s.text}>
<div class={s.guideText}>
<ol class={s.guideList}>
<li class={s.guideItem}>{intl.getMessage('setup_devices_ios_list_1')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_ios_list_2')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_ios_list_3')}</li>
<li class={s.guideItem}>{intl.getMessage('setup_devices_ios_list_4')}</li>
<li>{intl.getMessage('setup_devices_ios_list_1')}</li>
<li>{intl.getMessage('setup_devices_ios_list_2')}</li>
<li>{intl.getMessage('setup_devices_ios_list_3')}</li>
<li>{intl.getMessage('setup_devices_ios_list_4')}</li>
</ol>
</div>
</div>
);
const renderDnsDevicesList = () => (
<div class={s.deviceDnsList}>
<div>
<div class={s.guideParagraph}>
<div class={s.guideTitle}>
<strong>Android</strong>
@@ -327,7 +327,7 @@ const getDnsSettingsContent = (
</div>
) : (
<div class={s.dnsSettingsContent}>
<ul class={s.deviceDnsList}>
<ul class={s.guideList}>
<Show when={tlsAddress.length > 0}>
<li class={s.deviceDnsListItem}>
{intl.getMessage('setup_devices_dns_list_1', {
@@ -378,7 +378,7 @@ const getDnsSettingsContent = (
const DnsPrivacyLayout = (props: PlatformLayoutProps) => (
<div title={intl.getMessage('dns_privacy')}>
<div class={s.title}>{intl.getMessage('dns_privacy')}</div>
<div class={s.text}>
<div>
{getDnsSettingsContent(
props.dnsAddresses,
props.serverName,
@@ -5,7 +5,6 @@
height: 24px;
flex-shrink: 0;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.smallIcon {
+2 -1
View File
@@ -851,6 +851,7 @@ export const Icons = () => (
id="location"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
@@ -971,7 +972,7 @@ export const Icons = () => (
/>
</symbol>
<symbol id="check_tiny" viewBox="0 0 24 24" fill="none">
<symbol id="check_tiny" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path
d="M5 11.7665L10.5878 17L19 8"
stroke-width="1.5"
+6 -1
View File
@@ -16,6 +16,7 @@ type Props = {
hash?: string;
props?: LinkParams;
class?: string;
style?: JSX.CSSProperties | string;
type?: string;
stop?: boolean;
disabled?: boolean;
@@ -38,8 +39,10 @@ export const Link = (linkProps: Props) => {
});
const handleClick = (e: MouseEvent) => {
// A nested control cancelled the navigation (a row's action button), so
// the page must not jump to the top as if the link had been followed.
// Don't scroll to top when navigating to a specific section (scroll handled by the target page)
if (!linkProps.query?.[SCROLL_QUERY_KEY]) {
if (!e.defaultPrevented && !linkProps.query?.[SCROLL_QUERY_KEY]) {
setTimeout(() => {
window.scrollTo({ top: 0 });
}, 100);
@@ -62,6 +65,7 @@ export const Link = (linkProps: Props) => {
tabIndex={0}
title={linkProps.title}
class={cn(linkProps.class)}
style={linkProps.style}
data-testid={linkProps['data-testid']}
>
{linkProps.children}
@@ -72,6 +76,7 @@ export const Link = (linkProps: Props) => {
id={linkProps.id}
title={linkProps.title}
class={cn(theme.link.link, linkProps.class)}
style={linkProps.style}
href={linkPathBuilder(
linkProps.to,
linkProps.props,
@@ -49,3 +49,43 @@
padding: 0 0 0 16px;
}
}
.auth {
box-shadow: none;
border-bottom: 1px solid var(--default-cards-background);
.headerContent {
height: 48px;
flex-wrap: nowrap;
row-gap: 0;
padding: 0 12px;
}
.logoWrap {
order: 0;
padding: 0;
}
.languageWrap {
order: 0;
margin-left: auto;
min-width: 0;
}
@media screen and (min-width: 768px) {
box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.1);
.headerContent {
height: 80px;
padding: 0;
}
.logoWrap {
padding: 0 0 0 16px;
}
.languageWrap {
min-width: 90px;
}
}
}
@@ -1,4 +1,5 @@
import { type JSX } from 'solid-js';
import cn from 'clsx';
import { Logo } from 'panel/common/ui/Sidebar';
import intl, { type LocalesType } from 'panel/common/intl';
import { LanguageDropdown } from 'panel/common/ui/LanguageDropdown/LanguageDropdown';
@@ -16,6 +17,12 @@ type Props = {
center?: JSX.Element;
useLocalLanguage?: boolean;
hideLanguageDropdown?: boolean;
/**
* `auth` renders the compact header used by the login and
* forgot-password entry points: a single 48px row on mobile that grows to
* the regular 80px bar from the tablet breakpoint up.
*/
variant?: 'default' | 'auth';
};
export const PublicHeader = (props: Props) => {
@@ -38,7 +45,7 @@ export const PublicHeader = (props: Props) => {
const currentLanguage = () => dashboardState.language || intl.getUILanguage();
return (
<div class={styles.header}>
<div class={cn(styles.header, { [styles.auth]: props.variant === 'auth' })}>
<div class={styles.headerContent}>
<div class={styles.logoWrap}>
<Logo id="header" />
@@ -9,7 +9,12 @@
border-radius: 8px;
}
&:hover,
@media (hover: hover) {
&:hover:not(.switchDisabled) {
background-color: var(--page-background-additional);
}
}
&:focus-visible {
background-color: var(--page-background-additional);
}
@@ -22,7 +27,6 @@
.switchDisabled {
cursor: default;
&:hover,
&:focus {
background-color: transparent;
}
@@ -102,7 +106,7 @@
.value {
margin-top: 8px;
font-weight: var(--weight-semi-bold);
font-weight: var(--weight-medium);
color: var(--default-description-text);
display: -webkit-box;
-webkit-line-clamp: 2;
@@ -28,7 +28,12 @@
cursor: pointer;
transition: background-color var(--t2);
&:hover,
@media (hover: hover) {
&:hover {
background-color: var(--page-background-additional);
}
}
&:focus-within {
background-color: var(--page-background-additional);
}
@@ -80,7 +80,7 @@
border-radius: 8px;
padding: 8px 16px;
background-color: var(--default-cards-background);
box-shadow: 8px 8px 24px 0 rgba(0, 0, 0, 0.1);
box-shadow: var(--default-cards-box-shadow);
transition: background-color var(--t2);
@media (min-width: 768px) {
@@ -90,9 +90,11 @@
background-color: transparent;
box-shadow: none;
&:hover,
&:focus {
background-color: var(--page-background-additional);
@media (hover: hover) {
&:hover,
&:focus {
background-color: var(--page-background-additional);
}
}
}
@@ -107,6 +109,24 @@
cursor: pointer;
}
.tableRowLink {
cursor: pointer;
color: inherit;
text-decoration: none;
&:hover,
&:focus,
&:active {
color: inherit;
text-decoration: none;
}
&:focus-visible {
outline: 2px solid var(--product-primary-90);
outline-offset: -2px;
}
}
.sortIcon {
flex-shrink: 0;
opacity: 0;
+59 -25
View File
@@ -3,6 +3,8 @@ import { createStore } from 'solid-js/store';
import cn from 'clsx';
import { Loader } from 'panel/common/ui/Loader';
import { Link } from 'panel/common/ui/Link';
import type { QueryParams, RoutePathKey } from 'panel/components/Routes/Paths';
import { Pagination } from './blocks/Pagination/Pagination';
import { HeaderLabel } from './blocks/HeaderLabel/HeaderLabel';
@@ -49,6 +51,11 @@ export interface TableProps<T = any> {
onSortChange?: (key: string, direction: 'asc' | 'desc') => void;
getRowId?: (row: T, index: number) => string | number;
onRowClick?: (row: T) => void;
/**
* When it returns a target, the whole row is rendered as a link to it
* instead of a plain container.
*/
rowLink?: (row: T) => { to: RoutePathKey; query?: QueryParams } | undefined;
tableHeaderClass?: string;
tableRowClass?: string;
/**
@@ -201,9 +208,10 @@ export const Table = <T extends Record<string, any>>(props: TableProps<T>) => {
const hasData = () => paginatedData().length > 0;
// The rows-per-page select lives in the pagination footer, so the whole
// footer is hidden until there is at least a full page of rows.
// footer is hidden until the data needs more than one page at the default
// size.
const showPagination = () =>
(props.pagination ?? true) && sortedData().length >= DEFAULT_PAGE_SIZE;
(props.pagination ?? true) && sortedData().length > DEFAULT_PAGE_SIZE;
return (
<Show
@@ -272,31 +280,57 @@ export const Table = <T extends Record<string, any>>(props: TableProps<T>) => {
<Show when={hasData()}>
<For each={paginatedData()}>
{(row, index) => {
const rowLink = () => props.rowLink?.(row);
const rowCells = () => (
<For each={props.columns}>
{(column) => (
<div
class={cn(
s.tableCell,
s.tableBodyCell,
column.class,
{
[s.fitContent]: column.fitContent,
[s.clickableCell]:
!!props.onRowClick && !rowLink(),
},
)}
>
{renderCell(column, row, index())}
</div>
)}
</For>
);
return (
<div
class={cn(s.tableRow, props.tableRowClass)}
style={tableStyle()}
onClick={() => props.onRowClick?.(row)}
<Show
when={rowLink()}
fallback={
<div
class={cn(s.tableRow, props.tableRowClass)}
style={tableStyle()}
onClick={() => props.onRowClick?.(row)}
>
{rowCells()}
</div>
}
>
<For each={props.columns}>
{(column) => (
<div
class={cn(
s.tableCell,
s.tableBodyCell,
column.class,
{
[s.fitContent]: column.fitContent,
[s.clickableCell]:
!!props.onRowClick,
},
)}
>
{renderCell(column, row, index())}
</div>
)}
</For>
</div>
{(link) => (
<Link
to={link().to}
query={link().query}
class={cn(
s.tableRow,
s.tableRowLink,
props.tableRowClass,
)}
style={tableStyle()}
>
{rowCells()}
</Link>
)}
</Show>
);
}}
</For>
@@ -1,7 +1,3 @@
/* The tooltip trigger is a flex item of the header cell, so it needs the same
truncation-friendly sizing the bare label used to have. `Tooltip` renders
this wrapper even when it is disabled — then it carries no `data-part`
attribute and the global trigger rules in `Tooltip.pcss` do not apply. */
.labelWrapper {
min-width: 0;
overflow: hidden;
@@ -14,7 +10,6 @@
text-overflow: ellipsis;
}
/* Same menu padding the other tooltips in the app use. */
.tooltipContent {
padding: 16px;
}
@@ -1,8 +1,8 @@
import { Show } from 'solid-js';
import { Show, createSignal } from 'solid-js';
import cn from 'clsx';
import { Tooltip } from 'panel/common/ui/Tooltip';
import { useMediaQuery } from 'panel/helpers/useMediaQuery';
import { useIsTouchDevice } from 'panel/hooks/useMediaQuery';
import { useIsTruncated } from 'panel/hooks/useIsTruncated';
import theme from 'panel/lib/theme';
@@ -15,16 +15,20 @@ export type HeaderLabelProps = {
};
export const HeaderLabel = (props: HeaderLabelProps) => {
let labelRef: HTMLSpanElement | undefined;
const [labelRef, setLabelRef] = createSignal<HTMLSpanElement>();
const isTouch = useMediaQuery('(hover: none)');
const isTruncated = useIsTruncated(() => labelRef);
const isTouch = useIsTouchDevice();
const useTooltip = () => !!props.tooltip && !isTouch();
const isTruncated = useIsTruncated(labelRef, {
enabled: useTooltip,
source: () => props.text,
});
const label = (
<span
ref={labelRef}
ref={setLabelRef}
data-testid={`table-header-${props.columnKey}`}
class={cn(theme.text.t3, theme.text.condenced, theme.text.semibold, s.text)}
>
@@ -6,7 +6,7 @@ import theme from 'panel/lib/theme';
import { Select } from 'panel/common/controls/Select';
import intl from 'panel/common/intl';
import type { IOption } from 'panel/lib/helpers/utils';
import { useIsMobile } from 'panel/hooks/useIsMobile';
import { useIsMobile } from 'panel/hooks/useMediaQuery';
type Props = {
currentPage: number;
+2 -2
View File
@@ -2,7 +2,7 @@ import { type JSX, Show, createMemo, createSignal } from 'solid-js';
import cn from 'clsx';
import { Tooltip as ArkTooltip } from '@ark-ui/solid';
import { useMediaQuery } from 'panel/helpers/useMediaQuery';
import { useIsTouchDevice } from 'panel/hooks/useMediaQuery';
import { useOutsideDismiss } from 'panel/hooks/useOutsideDismiss';
import './Tooltip.pcss';
@@ -48,7 +48,7 @@ export const Tooltip = (props: Props) => {
}));
// Touch-device detection via hover-media query.
const isTouch = useMediaQuery('(hover: none)');
const isTouch = useIsTouchDevice();
// Managed open-state: hover-driven on desktop, tap-driven on touch.
const [hoverOpen, setHoverOpen] = createSignal(false);
@@ -0,0 +1,13 @@
.wrapper {
min-width: 0;
overflow: hidden;
}
.text {
min-width: 0;
}
.tooltipContent {
padding: 16px;
word-break: break-word;
}
@@ -0,0 +1,51 @@
import { createSignal } from 'solid-js';
import cn from 'clsx';
import { Tooltip } from 'panel/common/ui/Tooltip';
import { useIsTouchDevice } from 'panel/hooks/useMediaQuery';
import { useIsTruncated } from 'panel/hooks/useIsTruncated';
import theme from 'panel/lib/theme';
import s from './TruncatedText.module.pcss';
type Props = {
text: string;
/** Typography and width limits for the text element. */
class?: string;
testId?: string;
};
/**
* A single-line text that shows its full value in a tooltip once the browser
* clips it with an ellipsis.
*
* Replaces the native `title` attribute, which cannot be styled or delayed and
* renders a browser tooltip on top of the app's own overlays. Touch devices
* get no tooltip: there is no hover to open it with.
*/
export const TruncatedText = (props: Props) => {
const [textRef, setTextRef] = createSignal<HTMLSpanElement>();
const isTouch = useIsTouchDevice();
const isTruncated = useIsTruncated(textRef, {
enabled: () => !isTouch(),
source: () => props.text,
});
return (
<Tooltip
content={<div class={cn(theme.text.t3, s.tooltipContent)}>{props.text}</div>}
position="bottomLeft"
disabled={isTouch() || !isTruncated()}
class={s.wrapper}
>
<span
ref={setTextRef}
data-testid={props.testId}
class={cn(theme.common.textOverflow, s.text, props.class)}
>
{props.text}
</span>
</Tooltip>
);
};
@@ -0,0 +1 @@
export { TruncatedText } from './TruncatedText';
@@ -45,7 +45,7 @@
border-radius: 8px;
&:hover,
&:focus {
&:focus-visible {
background-color: var(--hovered-page-background);
}
}
@@ -138,7 +138,7 @@
border-radius: 8px;
&:hover,
&:focus {
&:focus-visible {
background-color: var(--hovered-page-background);
}
}
@@ -151,6 +151,8 @@ export const ScheduleModal = (props: Props) => {
height="big"
size="responsive"
isSearchable={false}
onCard
menuOnCard
/>
<Select
options={MINUTES_OPTIONS}
@@ -160,6 +162,8 @@ export const ScheduleModal = (props: Props) => {
height="big"
size="responsive"
isSearchable={false}
onCard
menuOnCard
/>
</div>
</div>
@@ -176,6 +180,8 @@ export const ScheduleModal = (props: Props) => {
height="big"
size="responsive"
isSearchable={false}
onCard
menuOnCard
/>
<Select
options={endTimeOptions().minutes}
@@ -185,6 +191,8 @@ export const ScheduleModal = (props: Props) => {
height="big"
size="responsive"
isSearchable={false}
onCard
menuOnCard
/>
</div>
</div>
@@ -220,6 +228,7 @@ export const ScheduleModal = (props: Props) => {
size="small"
onClick={props.onClose}
class={theme.dialog.button}
onCard
>
{intl.getMessage('cancel_btn')}
</Button>
@@ -44,8 +44,9 @@
font-size: 14px;
line-height: 1.5;
color: var(--default-main-text);
word-break: break-all;
overflow-wrap: break-word;
white-space: normal;
word-break: normal;
overflow-wrap: anywhere;
min-width: 0;
}
@@ -79,14 +80,15 @@
.copyBtnTopRight {
position: absolute;
top: 8px;
right: 8px;
top: 12px;
right: 12px;
}
.servicesIcons {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.servicesIconsList {
@@ -70,11 +70,15 @@ export const PersistentClientsTable = (props: Props) => {
<div class={theme.table.cellValueText}>
<div class={s.idsRow}>
<span class={cn(theme.common.textOverflow, s.idsText)}>
<span
class={cn(theme.common.textOverflow, s.idsText)}
data-testid="client-ids-text"
>
{firstId}
</span>
<Show when={hiddenCount > 0}>
<Tooltip
class={s.countDropdown}
overlayClass={s.idsTooltipOverlay}
content={
<div class={s.idsTooltip}>
@@ -102,7 +106,12 @@ export const PersistentClientsTable = (props: Props) => {
</div>
}
>
<span class={s.countLabel}>{hiddenCount}</span>
<span
class={s.countLabel}
data-testid="client-ids-count"
>
{hiddenCount}
</span>
</Tooltip>
</Show>
</div>
@@ -5,7 +5,11 @@ import { decodeSvg } from 'panel/helpers/helpers';
import s from './PersistentClientsTable.module.pcss';
const MAX_VISIBLE_SERVICES = 3;
/**
* Number of icons rendered inline before the rest collapse into the "+N" chip.
* Two is the largest value that fits the narrowest `blocked_services` cell.
*/
const MAX_VISIBLE_SERVICES = 2;
export type WebService = {
id: string;
@@ -27,7 +31,7 @@ export const ServiceIcons = (props: ServiceIconsProps) => {
const hiddenCount = createMemo(() => props.serviceIds.length - maxVisible());
return (
<div class={s.servicesIcons}>
<div class={s.servicesIcons} data-testid="service-icons">
<div class={s.servicesIconsList}>
<For each={visibleIds()}>
{(svcId) => {
@@ -37,6 +41,7 @@ export const ServiceIcons = (props: ServiceIconsProps) => {
return (
<div
class={s.serviceIcon}
data-testid="service-icon"
title={svc.name}
innerHTML={decodeSvg(svc.icon_svg)}
/>
@@ -51,7 +56,10 @@ export const ServiceIcons = (props: ServiceIconsProps) => {
overlayClass={s.servicesTooltipOverlay}
content={
<div class={s.servicesTooltip}>
<div class={s.servicesTooltipGrid}>
<div
class={s.servicesTooltipGrid}
data-testid="services-tooltip-grid"
>
<For each={props.serviceIds}>
{(svcId) => {
const svc = props.serviceMap.get(svcId);
@@ -71,7 +79,9 @@ export const ServiceIcons = (props: ServiceIconsProps) => {
</div>
}
>
<span class={s.countLabel}>{hiddenCount()}</span>
<span class={s.countLabel} data-testid="services-count">
{hiddenCount()}
</span>
</Tooltip>
</div>
</Show>
@@ -69,12 +69,12 @@
align-items: center;
gap: 4px;
min-width: 0;
}
.whoisIcon {
width: 16px;
height: 16px;
flex-shrink: 0;
.whoisIcon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
.whoisText {
@@ -1,13 +1,20 @@
import { createSignal, createMemo, createEffect, onCleanup, Show } from 'solid-js';
import {
createSignal,
createMemo,
createEffect,
onCleanup,
onMount,
Show,
} from 'solid-js';
import theme from 'panel/lib/theme';
import { PageLoader } from 'panel/common/ui/Loader';
import { dashboardState, toggleProtection, getClients } from 'panel/stores/dashboard';
import { statsState, getStats, getStatsConfig, enableStatistics } from 'panel/stores/stats';
import { accessState, getAccessList } from 'panel/stores/access';
import { getStoredStatsPeriod } from 'panel/helpers/statistics';
import { getStoredStatsPeriod, getClampedMaxInterval } from 'panel/helpers/statistics';
import { LocalStorageHelper, LOCAL_STORAGE_KEYS } from 'panel/helpers/localStorageHelper';
import { ONE_SECOND_IN_MS, HOUR, DAY, STATS_INTERVALS_DAYS } from 'panel/helpers/constants';
import { ONE_SECOND_IN_MS, DAY, STATS_INTERVALS_DAYS } from 'panel/helpers/constants';
import { Header, getPeriodLabel } from './blocks/Header/Header';
import { StatCards } from './blocks/StatCards';
@@ -59,49 +66,58 @@ export const Dashboard = () => {
}
});
const effectiveMaxStatsInterval = createMemo(() => {
const maxStatsInterval = statsState.interval || DAY;
return maxStatsInterval >= HOUR ? maxStatsInterval : DAY;
});
const maxStatsInterval = createMemo(() => getClampedMaxInterval(statsState.interval));
const periodIntervals = createMemo(() => {
const intervals = STATS_INTERVALS_DAYS.filter(
(interval) => interval <= effectiveMaxStatsInterval(),
);
const periodOptions = createMemo(() => {
const max = maxStatsInterval();
const intervals = STATS_INTERVALS_DAYS.filter((interval) => interval <= max);
if (!intervals.includes(effectiveMaxStatsInterval())) {
intervals.push(effectiveMaxStatsInterval());
if (!intervals.includes(max)) {
intervals.push(max);
}
return intervals.sort((a, b) => a - b);
// Until the first config attempt settles the list is built from the
// default DAY, so keep the stored period selectable to avoid showing a
// period the user did not pick.
const storedPeriod = selectedPeriod();
if (!statsState.configAttempted && !intervals.includes(storedPeriod)) {
intervals.push(storedPeriod);
}
return intervals
.sort((a, b) => a - b)
.map((interval) => ({ value: interval, label: getPeriodLabel(interval) }));
});
const periodOptions = createMemo(() =>
periodIntervals().map((interval) => ({
value: interval,
label: getPeriodLabel(interval),
})),
);
const effectivePeriod = createMemo(() => {
const options = periodOptions();
return Math.min(selectedPeriod(), options[options.length - 1]?.value ?? DAY);
});
const maxAvailablePeriod = createMemo(
() => periodIntervals()[periodIntervals().length - 1] || DAY,
);
const effectivePeriod = createMemo(() => Math.min(selectedPeriod(), maxAvailablePeriod()));
createEffect(() => {
const period = effectivePeriod();
getStats(period);
getStatsConfig();
onMount(() => {
getClients();
getAccessList();
getStatsConfig();
});
createEffect(() => {
const period = selectedPeriod();
// No stats request before the first config attempt settles: it would
// be clamped by the default DAY and paint 24h values before the real
// ones. Tracks `selectedPeriod` only; the interval/configLoaded
// changes alone must not re-fire the request.
if (!statsState.configAttempted) {
return;
}
// `getStats` clamps the period by the real server retention itself.
getStats(period);
});
const handleRefreshStats = () => {
getStats(effectivePeriod());
getStatsConfig();
getClients();
getAccessList();
getStatsConfig();
getStats(selectedPeriod());
};
const handleToggleProtection = (enabled: boolean, duration?: number) => {
@@ -121,20 +137,10 @@ export const Dashboard = () => {
const isLoading = () =>
statsState.processingStats || statsState.processingGetConfig || accessState.processing;
const hasStatsData = () =>
statsState.numDnsQueries > 0 ||
statsState.dnsQueries.length > 0 ||
statsState.topClients.length > 0 ||
statsState.topQueriedDomains.length > 0 ||
!statsState.enabled;
const [isInitialLoading, setIsInitialLoading] = createSignal(!hasStatsData());
createEffect(() => {
if (isInitialLoading() && !isLoading()) {
setIsInitialLoading(false);
}
});
// The page loader shows exactly once: until the first stats request
// settles. Later refetches keep the previous values painted, so the
// loader can never flash back in.
const isInitialLoading = () => !statsState.statsAttempted;
return (
<div class={theme.layout.container}>
@@ -8,7 +8,7 @@ import { EmptyState } from '../EmptyState';
import s from './GeneralStatistics.module.pcss';
import { StatRow } from '../StatRow';
import { RoutePath } from 'panel/components/Routes/Paths';
import { QUERY_LOG_REASON_FILTER } from 'panel/helpers/constants';
import { QUERY_LOG_REASON_FILTER, QUERY_LOG_STATUS_FILTER } from 'panel/helpers/constants';
type Props = {
numDnsQueries: number;
@@ -59,7 +59,10 @@ export const GeneralStatistics = (props: Props) => {
rowTheme="adsBlocked"
tooltip={intl.getMessage('ads_blocked_tooltip')}
linkTo={RoutePath.QueryLog}
query={{ reason: QUERY_LOG_REASON_FILTER.BLOCKED_BY_FILTER.QUERY }}
query={{
status: QUERY_LOG_STATUS_FILTER.BLOCKED.QUERY,
reason: QUERY_LOG_REASON_FILTER.BLOCKED_BY_FILTER.QUERY,
}}
/>
<StatRow
@@ -70,7 +73,10 @@ export const GeneralStatistics = (props: Props) => {
rowTheme="threatsBlocked"
tooltip={intl.getMessage('threats_blocked_tooltip')}
linkTo={RoutePath.QueryLog}
query={{ reason: QUERY_LOG_REASON_FILTER.BLOCKED_BY_THREATS.QUERY }}
query={{
status: QUERY_LOG_STATUS_FILTER.BLOCKED.QUERY,
reason: QUERY_LOG_REASON_FILTER.BLOCKED_BY_THREATS.QUERY,
}}
/>
<StatRow
@@ -82,6 +88,7 @@ export const GeneralStatistics = (props: Props) => {
tooltip={intl.getMessage('adult_websites_blocked_tooltip')}
linkTo={RoutePath.QueryLog}
query={{
status: QUERY_LOG_STATUS_FILTER.BLOCKED.QUERY,
reason: QUERY_LOG_REASON_FILTER.BLOCKED_BY_PARENTAL_CONTROL.QUERY,
}}
/>
@@ -94,7 +101,10 @@ export const GeneralStatistics = (props: Props) => {
rowTheme="safeSearchUsed"
tooltip={intl.getMessage('safe_search_used_tooltip')}
linkTo={RoutePath.QueryLog}
query={{ reason: QUERY_LOG_REASON_FILTER.SAFE_SEARCH.QUERY }}
query={{
status: QUERY_LOG_STATUS_FILTER.REWRITTEN.QUERY,
reason: QUERY_LOG_REASON_FILTER.SAFE_SEARCH.QUERY,
}}
/>
<div class={s.rowDivider} />
@@ -9,7 +9,7 @@ import { Select } from 'panel/common/controls/Select';
import { Icon } from 'panel/common/ui/Icon';
import { Link } from 'panel/common/ui/Link';
import { RoutePath, SCROLL_QUERY_KEY } from 'panel/components/Routes/Paths';
import { useIsMobile } from 'panel/hooks/useIsMobile';
import { useIsMobile } from 'panel/hooks/useMediaQuery';
import { DISABLE_PROTECTION_TIMINGS, ONE_SECOND_IN_MS } from 'panel/helpers/constants';
import { msToSeconds, msToMinutes, msToHours } from 'panel/helpers/helpers';
@@ -0,0 +1,9 @@
/* The shared Tooltip stylesheet makes its trigger `inline-flex`, which would
shrink-wrap the row and place consecutive rows on one line. The doubled
class covers the disabled (touch) wrapper, which carries no trigger
attributes; the attribute-qualified selector out-specifies the global
Tooltip trigger rule whatever the stylesheet order in the bundle is. */
.rowTrigger.rowTrigger,
[data-scope='tooltip'][data-part='trigger'].rowTrigger {
display: block;
}
@@ -0,0 +1,37 @@
import { type JSX } from 'solid-js';
import cn from 'clsx';
import { Tooltip } from 'panel/common/ui/Tooltip';
import { useIsTouchDevice } from 'panel/hooks/useMediaQuery';
import s from './RowTooltip.module.pcss';
type Props = {
content: JSX.Element;
class?: string;
/** Overrides the overlay, e.g. to make a non-interactive card transparent to the pointer. */
overlayClass?: string;
children: JSX.Element;
};
/**
* Makes a whole Dashboard row a tooltip trigger.
*
* Hover-only: on touch devices the tooltip is disabled entirely, so a tap
* follows the row link instead of revealing the details.
*/
export const RowTooltip = (props: Props) => {
const isTouch = useIsTouchDevice();
return (
<Tooltip
content={props.content}
position="bottomLeft"
disabled={isTouch()}
overlayClass={props.overlayClass}
class={cn(s.rowTrigger, props.class)}
>
{props.children}
</Tooltip>
);
};
@@ -0,0 +1 @@
export { RowTooltip } from './RowTooltip';
@@ -45,7 +45,10 @@ export const StatCards = (props: Props) => {
percentValue={blockedPercent()}
cardTheme={CARDS_THEME.ADS}
linkTo={RoutePath.QueryLog}
query={{ status: QUERY_LOG_STATUS_FILTER.BLOCKED.QUERY }}
query={{
status: QUERY_LOG_STATUS_FILTER.BLOCKED.QUERY,
reason: QUERY_LOG_REASON_FILTER.BLOCKED_BY_FILTER.QUERY,
}}
/>
<StatCard
value={props.numReplacedSafebrowsing}
@@ -56,7 +59,10 @@ export const StatCards = (props: Props) => {
percentValue={threatsPercent()}
cardTheme={CARDS_THEME.THREATS}
linkTo={RoutePath.QueryLog}
query={{ reason: QUERY_LOG_REASON_FILTER.BLOCKED_BY_THREATS.QUERY }}
query={{
status: QUERY_LOG_STATUS_FILTER.BLOCKED.QUERY,
reason: QUERY_LOG_REASON_FILTER.BLOCKED_BY_THREATS.QUERY,
}}
/>
<StatCard
value={props.numReplacedParental}
@@ -67,7 +73,10 @@ export const StatCards = (props: Props) => {
percentValue={parentalPercent()}
cardTheme={CARDS_THEME.ADULT}
linkTo={RoutePath.QueryLog}
query={{ reason: QUERY_LOG_REASON_FILTER.BLOCKED_BY_PARENTAL_CONTROL.QUERY }}
query={{
status: QUERY_LOG_STATUS_FILTER.BLOCKED.QUERY,
reason: QUERY_LOG_REASON_FILTER.BLOCKED_BY_PARENTAL_CONTROL.QUERY,
}}
/>
</div>
);
@@ -117,14 +117,33 @@
white-space: nowrap;
}
.queryCountLink {
.queryCountValue {
color: inherit;
}
.statRowLink {
cursor: pointer;
color: inherit;
text-decoration: none;
border-radius: 4px;
&:hover,
&:focus {
&:focus,
&:active {
color: inherit;
text-decoration: underline;
text-decoration: none;
}
@media (hover: hover) {
&:hover,
&:focus-visible {
background-color: var(--hovered-page-background);
}
}
&:focus-visible {
outline: 2px solid var(--product-primary-90);
outline-offset: -2px;
}
}
@@ -3,13 +3,12 @@ import { Show } from 'solid-js';
import intl from 'panel/common/intl';
import { Icon, type IconType } from 'panel/common/ui/Icon';
import theme from 'panel/lib/theme';
import { Tooltip } from 'panel/common/ui/Tooltip';
import { QueriesTooltip } from 'panel/common/ui/QueriesTooltip';
import { Link } from 'panel/common/ui/Link';
import { type QueryParams, type RoutePathKey } from 'panel/components/Routes/Paths';
import cn from 'clsx';
import { formatCompactNumber } from 'panel/helpers/helpers';
import { RowTooltip } from '../RowTooltip';
import s from './StatRow.module.pcss';
export type StatRowProps = {
@@ -52,20 +51,22 @@ export const StatRow = (props: StatRowProps) => {
</div>
);
return (
<div class={cn(s.statRow, s[props.rowTheme])}>
const rowClass = () => cn(s.statRow, s[props.rowTheme], props.linkTo && s.statRowLink);
const rowContent = () => (
<>
<div class={s.statRowDropdown}>
<Tooltip
position="bottomLeft"
overlayClass={s.queryTooltipOverlay}
content={<div class={cn(theme.text.t3, s.statTooltip)}>{props.tooltip}</div>}
class={theme.common.noShrink}
<div
class={cn(
theme.text.t3,
theme.text.condenced,
theme.common.noShrink,
s.statRowLeft,
)}
>
<div class={cn(theme.text.t3, theme.text.condenced, s.statRowLeft)}>
<Icon icon={props.icon} class={s.tableRowIcon} />
{props.label}
</div>
</Tooltip>
<Icon icon={props.icon} class={s.tableRowIcon} />
{props.label}
</div>
</div>
<div class={s.statRowValue}>
@@ -78,25 +79,10 @@ export const StatRow = (props: StatRowProps) => {
}
>
<div class={s.dropdownWrapper}>
<QueriesTooltip count={props.value as number}>
<div class={cn(theme.text.t3, theme.text.condenced, s.queryCount)}>
<Show when={props.linkTo} fallback={queriesValue()}>
<Link
to={props.linkTo}
query={props.query}
class={cn(
theme.text.t3,
theme.text.condenced,
s.queryCount,
s.queryCountLink,
)}
>
{queriesValue()}
</Link>
</Show>
{queriesPercent()}
</div>
</QueriesTooltip>
<div class={cn(theme.text.t3, theme.text.condenced, s.queryCount)}>
<span class={s.queryCountValue}>{queriesValue()}</span>
{queriesPercent()}
</div>
</div>
</Show>
@@ -118,6 +104,24 @@ export const StatRow = (props: StatRowProps) => {
/>
</div>
</Show>
</div>
</>
);
// The description is plain text, so its overlay must not swallow the
// pointer: it overlaps the row below and would otherwise keep that row from
// receiving its own hover.
return (
<RowTooltip
content={<div class={cn(theme.text.t3, s.statTooltip)}>{props.tooltip}</div>}
overlayClass={s.queryTooltipOverlay}
>
<Show when={props.linkTo} fallback={<div class={rowClass()}>{rowContent()}</div>}>
{(to) => (
<Link to={to()} query={props.query} class={rowClass()}>
{rowContent()}
</Link>
)}
</Show>
</RowTooltip>
);
};

Some files were not shown because too many files have changed in this diff Show More