mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2026-10-12 02:01:26 +00:00
fix
This commit is contained in:
@@ -54,4 +54,27 @@ describe('Select', () => {
|
||||
// The indicator (arrow icon) should be inside the trigger.
|
||||
expect(indicator).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders menuFooter inside the dropdown content', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(() => (
|
||||
<Select
|
||||
options={OPTIONS}
|
||||
value={OPTIONS[0]}
|
||||
onChange={() => {}}
|
||||
menuFooter={<div data-testid="select-footer">Footer</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
const trigger = document.querySelector(
|
||||
'[data-scope="select"][data-part="trigger"]',
|
||||
) as HTMLElement;
|
||||
await user.click(trigger);
|
||||
|
||||
const footer = document.querySelector('[data-testid="select-footer"]');
|
||||
expect(footer).toBeTruthy();
|
||||
expect(
|
||||
footer?.closest('[data-scope="select"][data-part="content"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,8 @@ interface SelectProps<
|
||||
showIcons?: boolean;
|
||||
showOptionIcon?: boolean;
|
||||
optionTestIdPrefix?: string;
|
||||
/** Optional footer rendered after the scrollable item list inside the dropdown */
|
||||
menuFooter?: JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,15 +245,17 @@ export const Select = <
|
||||
}}
|
||||
positioning={{
|
||||
placement:
|
||||
props.menuPosition === 'right'
|
||||
? props.menuPlacement === 'top'
|
||||
? ('top-end' as const)
|
||||
: ('bottom-end' as const)
|
||||
: props.menuPlacement === 'top'
|
||||
? ('top-end' as const)
|
||||
: ('bottom-end' as const),
|
||||
props.menuPlacement === 'top'
|
||||
? ('top-start' as const)
|
||||
: ('bottom-start' as const),
|
||||
sameWidth: true,
|
||||
flip: ['bottom-end', 'top-end'],
|
||||
fitViewport: true,
|
||||
flip:
|
||||
props.menuPlacement === 'auto'
|
||||
? true
|
||||
: props.menuPlacement === 'top'
|
||||
? ['top-start', 'bottom-start']
|
||||
: ['bottom-start', 'top-start'],
|
||||
}}
|
||||
>
|
||||
<Show when={!props.isDropdownSelect}>
|
||||
@@ -350,6 +354,7 @@ export const Select = <
|
||||
</div>
|
||||
</Show>
|
||||
</ArkSelect.ItemGroup>
|
||||
<Show when={props.menuFooter}>{props.menuFooter}</Show>
|
||||
</Show>
|
||||
</ArkSelect.Content>
|
||||
</ArkSelect.Positioner>
|
||||
@@ -377,15 +382,17 @@ export const Select = <
|
||||
}}
|
||||
positioning={{
|
||||
placement:
|
||||
props.menuPosition === 'right'
|
||||
? props.menuPlacement === 'top'
|
||||
? ('top-end' as const)
|
||||
: ('bottom-end' as const)
|
||||
: props.menuPlacement === 'top'
|
||||
? ('top-end' as const)
|
||||
: ('bottom-end' as const),
|
||||
props.menuPlacement === 'top'
|
||||
? ('top-start' as const)
|
||||
: ('bottom-start' as const),
|
||||
sameWidth: true,
|
||||
flip: ['bottom-end', 'top-end'],
|
||||
fitViewport: true,
|
||||
flip:
|
||||
props.menuPlacement === 'auto'
|
||||
? true
|
||||
: props.menuPlacement === 'top'
|
||||
? ['top-start', 'bottom-start']
|
||||
: ['bottom-start', 'top-start'],
|
||||
}}
|
||||
>
|
||||
<Show when={!props.isDropdownSelect}>
|
||||
@@ -502,6 +509,7 @@ export const Select = <
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={props.menuFooter}>{props.menuFooter}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</ArkCombobox.ItemGroup>
|
||||
|
||||
@@ -27,14 +27,8 @@
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
color: var(--default-gray-icons);
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--hovered-page-background);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { type JSX, Show, untrack } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import cn from 'clsx';
|
||||
import { LinkParams, linkPathBuilder, RoutePathKey } from 'panel/components/Routes/Paths';
|
||||
import {
|
||||
LinkParams,
|
||||
linkPathBuilder,
|
||||
RoutePathKey,
|
||||
SCROLL_QUERY_KEY,
|
||||
} from 'panel/components/Routes/Paths';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
@@ -30,9 +35,12 @@ export const Link = (linkProps: Props) => {
|
||||
});
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0 });
|
||||
}, 100);
|
||||
// Don't scroll to top when navigating to a specific section (scroll handled by the target page)
|
||||
if (!linkProps.query?.[SCROLL_QUERY_KEY]) {
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0 });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
if (linkProps.stop) {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Switch } from 'panel/common/controls/Switch';
|
||||
import { Dropdown } from 'panel/common/ui/Dropdown';
|
||||
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 { DISABLE_PROTECTION_TIMINGS, ONE_SECOND_IN_MS } from 'panel/helpers/constants';
|
||||
import { msToSeconds, msToMinutes, msToHours } from 'panel/helpers/helpers';
|
||||
@@ -124,6 +126,25 @@ export const Header = (props: Props) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const periodSettingsFooter = (
|
||||
<div class={cn(s.periodSettingsFooter, theme.select.option_check)}>
|
||||
<Icon icon="settings" class={theme.select.icon} />
|
||||
<div class={cn(theme.text.t2, theme.text.condenced)}>
|
||||
{intl.getMessage('period_notify', {
|
||||
a: (text: string) => (
|
||||
<Link
|
||||
to={RoutePath.SettingsPage}
|
||||
query={{ [SCROLL_QUERY_KEY]: 'stats_config' }}
|
||||
class={cn(theme.link.link, theme.link.noDecoration)}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div class={s.header}>
|
||||
<div class={s.headerLeft}>
|
||||
@@ -217,6 +238,7 @@ export const Header = (props: Props) => {
|
||||
borderless={!isMobile()}
|
||||
menuSize="big"
|
||||
menuPosition="right"
|
||||
menuFooter={periodSettingsFooter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ export const ListsTable = (props: Props) => {
|
||||
},
|
||||
accessor: 'enabled',
|
||||
sortable: false,
|
||||
fitContent: true,
|
||||
width: 64,
|
||||
className: s.cellNameToggleOuter,
|
||||
render: (value: boolean, row: Filter) => {
|
||||
const { name, url, enabled } = row;
|
||||
|
||||
@@ -31,6 +31,9 @@ export const RoutePath = {
|
||||
|
||||
export type RoutePathKey = keyof typeof RoutePath;
|
||||
|
||||
/** Query param key used to pass a target element ID for scroll-to-section navigation. */
|
||||
export const SCROLL_QUERY_KEY = 'section';
|
||||
|
||||
export const Paths: Record<RoutePathKey, string> = {
|
||||
Dashboard: pathBuilder('dashboard'),
|
||||
Logs: pathBuilder('logs'),
|
||||
@@ -63,6 +66,7 @@ export const linkPathBuilder = (
|
||||
route: RoutePathKey,
|
||||
params?: LinkParams,
|
||||
query?: Partial<Record<string, string | number | boolean>>,
|
||||
hash?: string,
|
||||
) => {
|
||||
let path = Paths[route];
|
||||
if (params) {
|
||||
@@ -75,5 +79,9 @@ export const linkPathBuilder = (
|
||||
path += `?${qs.stringify(query)}`;
|
||||
}
|
||||
|
||||
if (hash) {
|
||||
path += `#${hash}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createMemo, createEffect, onMount, For, Show, untrack } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { SCROLL_QUERY_KEY } from 'panel/components/Routes/Paths';
|
||||
|
||||
import { useSearchParams } from '@solidjs/router';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import { Checkbox } from 'panel/common/controls/Checkbox';
|
||||
import theme from 'panel/lib/theme';
|
||||
@@ -76,19 +80,20 @@ export const Settings = () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Handle hash scroll
|
||||
const [searchParams] = useSearchParams<{ [SCROLL_QUERY_KEY]?: string }>();
|
||||
|
||||
// Handle query-param-based scroll to section
|
||||
createEffect(() => {
|
||||
if (!isLoading()) {
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
try {
|
||||
const el = document.querySelector(hash);
|
||||
const section = searchParams[SCROLL_QUERY_KEY];
|
||||
if (section) {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(section);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth' });
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 80;
|
||||
window.scrollTo({ top, behavior: 'smooth' });
|
||||
}
|
||||
} catch {
|
||||
// Hash is not a valid CSS selector (e.g. route path like #/dashboard)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { createSignal, createEffect, createMemo, untrack } from 'solid-js';
|
||||
|
||||
import {
|
||||
ALL_INTERFACES_IP,
|
||||
STANDARD_DNS_PORT,
|
||||
STANDARD_WEB_PORT,
|
||||
} from 'panel/helpers/constants';
|
||||
import { ALL_INTERFACES_IP, STANDARD_DNS_PORT, STANDARD_WEB_PORT } from 'panel/helpers/constants';
|
||||
import { validateInstallPort } from 'panel/helpers/validators';
|
||||
|
||||
import type { DnsConfig, SettingsFormValues, WebConfig, ConfigType } from '../types';
|
||||
|
||||
@@ -169,7 +169,6 @@ export const saveClient = async (): Promise<boolean> => {
|
||||
await apiClient.updateClient({ name: state.originalName, data: config });
|
||||
clearClientForm();
|
||||
await getClients();
|
||||
addSuccessToast({ message: intl.getMessage('client_edit_success') });
|
||||
return true;
|
||||
} catch (error) {
|
||||
addErrorToast({ error });
|
||||
@@ -183,7 +182,7 @@ export const saveClient = async (): Promise<boolean> => {
|
||||
await apiClient.addClient(config);
|
||||
clearClientForm();
|
||||
await getClients();
|
||||
addSuccessToast({ message: intl.getMessage('client_add_success') });
|
||||
addSuccessToast({ message: intl.getMessage('client_added') });
|
||||
return true;
|
||||
} catch (error) {
|
||||
addErrorToast({ error });
|
||||
|
||||
@@ -267,7 +267,7 @@ export const refreshFilters = async (config: any) => {
|
||||
setState('processingRefreshFilters', false);
|
||||
const updated = (data as any)?.updated || 0;
|
||||
if (updated > 0) {
|
||||
addSuccessToast(intl.getMessage('list_updated', { count: updated }));
|
||||
addSuccessToast(intl.getPlural('list_updated', updated));
|
||||
} else {
|
||||
addSuccessToast(intl.getMessage('all_lists_up_to_date_toast'));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user