mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2026-10-12 02:01:26 +00:00
AGH-177 design review UI fixes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": "تم مسح سجل الاستعلام",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
+6
-4
@@ -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 {
|
||||
|
||||
+11
-2
@@ -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>
|
||||
|
||||
+5
-5
@@ -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
Reference in New Issue
Block a user