mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2026-10-23 03:27:43 +09:00
AGH-246 Fix table, cache, and logout bugs
This commit is contained in:
1 parent
fb7774fa5c
commit
b13042b071
11 files changed
+1175
-27
No files matched your search
+2
-1
@@ -1,7 +1,7 @@
|
||||
# This comment is used to simplify checking local copies of the file. Bump
|
||||
# this number every time a significant change is made to this file.
|
||||
#
|
||||
# AdGuard-Project-Version: 1
|
||||
# AdGuard-Project-Version: 2
|
||||
|
||||
# Please, DO NOT put your text editors' temporary files here. The more are
|
||||
# added, the harder it gets to maintain and manage projects' gitignores. Put
|
||||
@@ -23,6 +23,7 @@
|
||||
/client/playwright-report/
|
||||
/client/playwright/.cache/
|
||||
/client/test-results/
|
||||
/client_v2/.env.local
|
||||
/client_v2/blob-report/
|
||||
/client_v2/playwright-report/
|
||||
/client_v2/playwright/.cache/
|
||||
|
||||
@@ -54,6 +54,12 @@ NOTE: Add new changes BELOW THIS COMMENT.
|
||||
|
||||
- Passwords containing emoji being rejected by the first-run setup wizard in the new web interface.
|
||||
|
||||
- Cache size and TTL override rows being locked while the DNS cache was disabled in the new web interface ([#8644]).
|
||||
|
||||
- Tables staying on an empty page after the last row of that page was deleted in the new web interface ([#8644]).
|
||||
|
||||
- The *Log out* button opening the login page in a new tab in the new web interface ([#8661]).
|
||||
|
||||
### Removed
|
||||
|
||||
- The support for macOS 13 Ventura, see [Go 1.27 release notes][go-1.27-notes].
|
||||
@@ -66,6 +72,8 @@ NOTE: Add new changes BELOW THIS COMMENT.
|
||||
[#8613]: https://github.com/AdguardTeam/AdGuardHome/issues/8613
|
||||
[#8636]: https://github.com/AdguardTeam/AdGuardHome/issues/8636
|
||||
[#8637]: https://github.com/AdguardTeam/AdGuardHome/issues/8637
|
||||
[#8644]: https://github.com/AdguardTeam/AdGuardHome/issues/8644
|
||||
[#8661]: https://github.com/AdguardTeam/AdGuardHome/issues/8661
|
||||
[go-1.27.2]: https://groups.google.com/g/golang-announce/c/U2fTuyDJznI
|
||||
[go-1.27-notes]: https://go.dev/doc/go1.27#darwin
|
||||
|
||||
|
||||
@@ -503,6 +503,18 @@ describe('numeric range validators', () => {
|
||||
expect(validateBetween(16, 0, 32)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validateBetween returns error for non-finite values', () => {
|
||||
expect(validateBetween(NaN, 0, 32)).toBeTruthy();
|
||||
expect(validateBetween(Infinity, 0, 32)).toBeTruthy();
|
||||
expect(validateBetween(-Infinity, 0, 32)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('validateBetween optionally rejects non-integer values', () => {
|
||||
expect(validateBetween(0.5, 0, 32, { integer: true })).toBeTruthy();
|
||||
expect(validateBetween(0.5, 0, 32)).toBeUndefined();
|
||||
expect(validateBetween(16, 0, 32, { integer: true })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validateMinValue returns error when value is below min', () => {
|
||||
expect(validateMinValue(0, 1)).toBeTruthy();
|
||||
expect(validateMinValue(1, 1)).toBeUndefined();
|
||||
@@ -539,6 +551,10 @@ describe('validateCacheSize', () => {
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns error for a fractional size when enabled', () => {
|
||||
expect(validateCacheSize(1.5, true)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined at UINT32_MAX boundary', () => {
|
||||
expect(validateCacheSize(4294967295, true)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render } from '@solidjs/testing-library';
|
||||
import { MemoryRouter, Route, createMemoryHistory } from '@solidjs/router';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('panel/common/intl', async () =>
|
||||
(await import('panel/__tests__/helpers/copy')).createIntlMock(),
|
||||
);
|
||||
|
||||
import { Menu } from 'panel/common/ui/Menu';
|
||||
|
||||
const renderMenu = () => {
|
||||
const history = createMemoryHistory();
|
||||
history.set({ value: '/' });
|
||||
|
||||
return render(() => (
|
||||
<MemoryRouter history={history}>
|
||||
<Route
|
||||
path="/*"
|
||||
component={() => <Menu accountSubMenu={false} setAccountSubMenu={vi.fn()} />}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
));
|
||||
};
|
||||
|
||||
describe('Menu', () => {
|
||||
it('logs out in the current tab', () => {
|
||||
const { container } = renderMenu();
|
||||
const logoutLink = container.querySelector('#sign_out');
|
||||
|
||||
expect(logoutLink).not.toBeNull();
|
||||
expect(logoutLink).toHaveAttribute('href', 'control/logout');
|
||||
expect(logoutLink).not.toHaveAttribute('target');
|
||||
expect(logoutLink).toHaveAttribute('rel', 'external');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
import { createSignal, For } from 'solid-js';
|
||||
import { render, screen, fireEvent } from '@solidjs/testing-library';
|
||||
import { MemoryRouter, Route, createMemoryHistory } from '@solidjs/router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Table, type TableColumn } from 'panel/common/ui/Table';
|
||||
|
||||
type Row = {
|
||||
name: string | null;
|
||||
age: number;
|
||||
city?: string;
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
onSortChange: vi.fn(),
|
||||
onPageSizeChange: vi.fn(),
|
||||
onRowClick: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/common/intl', async () =>
|
||||
(await import('panel/__tests__/helpers/copy')).createIntlMock(),
|
||||
);
|
||||
|
||||
vi.mock('panel/common/controls/Select', () => ({
|
||||
Select: (props: any) => (
|
||||
<select
|
||||
data-testid="page-size-select"
|
||||
value={props.value?.value}
|
||||
onChange={(e: Event) => {
|
||||
const select = e.currentTarget as HTMLSelectElement;
|
||||
const option = props.options.find(
|
||||
(item: any) => String(item.value) === select.value,
|
||||
);
|
||||
props.onChange(option);
|
||||
}}
|
||||
>
|
||||
<For each={props.options}>
|
||||
{(option: any) => <option value={option.value}>{option.label}</option>}
|
||||
</For>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
const columns: TableColumn<Row>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: { text: 'Name header', class: 'name-header' },
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
class: 'name-column-cell',
|
||||
render: (value: string | null) => (
|
||||
<span data-testid="name-cell">{String(value ?? '')}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'age',
|
||||
header: { text: 'Age header', class: 'age-header' },
|
||||
accessor: 'age',
|
||||
sortable: true,
|
||||
render: (value: number) => <span data-testid="age-cell">{value}</span>,
|
||||
},
|
||||
{
|
||||
key: 'city',
|
||||
header: { text: 'City header', class: 'city-header' },
|
||||
accessor: 'city',
|
||||
sortable: false,
|
||||
render: (value: string | undefined) => <span data-testid="city-cell">{value ?? ''}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const makeRows = (count: number): Row[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
name: `row-${String(index).padStart(2, '0')}`,
|
||||
age: index,
|
||||
city: 'City',
|
||||
}));
|
||||
|
||||
const getNames = () => screen.getAllByTestId('name-cell').map((cell) => cell.textContent);
|
||||
|
||||
const renderTable = (options: {
|
||||
data: Row[];
|
||||
columns?: TableColumn<Row>[];
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
const { data, columns: tableColumns = columns, ...rest } = options;
|
||||
|
||||
return render(() => <Table<Row> data={data} columns={tableColumns} {...rest} />);
|
||||
};
|
||||
|
||||
const getHeader = (container: HTMLElement, selector: string) => {
|
||||
const header = container.querySelector(selector);
|
||||
if (!(header instanceof HTMLElement)) {
|
||||
throw new Error(`Table header not found: ${selector}`);
|
||||
}
|
||||
|
||||
return header;
|
||||
};
|
||||
|
||||
describe('Table', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders headers and values from accessors', () => {
|
||||
renderTable({ data: [{ name: 'Alice', age: 30, city: 'Paris' }] });
|
||||
|
||||
expect(screen.getByText('Name header')).toBeDefined();
|
||||
expect(screen.getByText('Age header')).toBeDefined();
|
||||
expect(screen.getByText('City header')).toBeDefined();
|
||||
expect(screen.getByText('Alice')).toBeDefined();
|
||||
expect(screen.getByText('30')).toBeDefined();
|
||||
expect(screen.getByText('Paris')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders custom cell content with value, row and index', () => {
|
||||
const renderCell = vi.fn((value: string, row: Row, index: number) => (
|
||||
<span data-testid="custom-cell">{`${index}:${row.name}:${value}`}</span>
|
||||
));
|
||||
|
||||
renderTable({
|
||||
data: [
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 40 },
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
key: 'name',
|
||||
header: { text: 'Name' },
|
||||
accessor: 'name',
|
||||
render: renderCell,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(renderCell).toHaveBeenNthCalledWith(1, 'Alice', { name: 'Alice', age: 30 }, 0);
|
||||
expect(renderCell).toHaveBeenNthCalledWith(2, 'Bob', { name: 'Bob', age: 40 }, 1);
|
||||
expect(screen.getByText('0:Alice:Alice')).toBeDefined();
|
||||
expect(screen.getByText('1:Bob:Bob')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders values from function accessors', () => {
|
||||
renderTable({
|
||||
data: [{ name: 'Alice', age: 30 }],
|
||||
columns: [
|
||||
{
|
||||
key: 'name',
|
||||
header: { text: 'Name' },
|
||||
accessor: (row) => row.name?.toUpperCase(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByText('ALICE')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders an empty cell when the accessor value is missing', () => {
|
||||
const { container } = renderTable({
|
||||
data: [{ name: 'Alice', age: 30 }],
|
||||
columns: [{ key: 'city', header: { text: 'City' }, accessor: 'city' }],
|
||||
tableRowClass: 'test-row',
|
||||
});
|
||||
|
||||
expect(container.querySelector('.test-row')?.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('renders a custom column header', () => {
|
||||
renderTable({
|
||||
data: [{ name: 'Alice', age: 30 }],
|
||||
columns: [
|
||||
{
|
||||
key: 'name',
|
||||
header: {
|
||||
text: 'Name',
|
||||
render: () => <span data-testid="custom-header">Custom name</span>,
|
||||
},
|
||||
accessor: 'name',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('custom-header')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('shows the empty state when there are no rows', () => {
|
||||
renderTable({ data: [], emptyTable: <div>Nothing here</div> });
|
||||
|
||||
expect(screen.getByText('Nothing here')).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not show the empty state when rows exist', () => {
|
||||
renderTable({
|
||||
data: [{ name: 'Alice', age: 30 }],
|
||||
emptyTable: <div>Nothing here</div>,
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Nothing here')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading', () => {
|
||||
it('shows a loader instead of the table while loading', () => {
|
||||
const { container } = renderTable({
|
||||
data: [{ name: 'Alice', age: 30 }],
|
||||
loading: true,
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Name header')).toBeNull();
|
||||
expect(screen.queryByText('Alice')).toBeNull();
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
const unsortedData: Row[] = [
|
||||
{ name: 'Charlie', age: 3 },
|
||||
{ name: 'Alice', age: 1 },
|
||||
{ name: 'Bob', age: 2 },
|
||||
];
|
||||
|
||||
it('applies the default sort on mount', () => {
|
||||
renderTable({
|
||||
data: unsortedData,
|
||||
defaultSort: { key: 'name', direction: 'asc' },
|
||||
});
|
||||
|
||||
expect(getNames()).toEqual(['Alice', 'Bob', 'Charlie']);
|
||||
});
|
||||
|
||||
it('applies a descending default sort on mount', () => {
|
||||
renderTable({
|
||||
data: unsortedData,
|
||||
defaultSort: { key: 'age', direction: 'desc' },
|
||||
});
|
||||
|
||||
expect(getNames()).toEqual(['Charlie', 'Bob', 'Alice']);
|
||||
});
|
||||
|
||||
it('toggles between ascending and descending when a header is clicked', () => {
|
||||
const { container } = renderTable({ data: unsortedData });
|
||||
|
||||
fireEvent.click(getHeader(container, '.name-header'));
|
||||
expect(getNames()).toEqual(['Alice', 'Bob', 'Charlie']);
|
||||
|
||||
fireEvent.click(getHeader(container, '.name-header'));
|
||||
expect(getNames()).toEqual(['Charlie', 'Bob', 'Alice']);
|
||||
});
|
||||
|
||||
it('sorts strings case-insensitively', () => {
|
||||
const { container } = renderTable({
|
||||
data: [
|
||||
{ name: 'beta', age: 2 },
|
||||
{ name: 'Alpha', age: 1 },
|
||||
{ name: 'gamma', age: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(getHeader(container, '.name-header'));
|
||||
|
||||
expect(getNames()).toEqual(['Alpha', 'beta', 'gamma']);
|
||||
});
|
||||
|
||||
it('keeps null values last in both directions', () => {
|
||||
const { container } = renderTable({
|
||||
data: [
|
||||
{ name: 'b', age: 2 },
|
||||
{ name: null, age: 3 },
|
||||
{ name: 'a', age: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(getHeader(container, '.name-header'));
|
||||
expect(getNames()).toEqual(['a', 'b', '']);
|
||||
|
||||
fireEvent.click(getHeader(container, '.name-header'));
|
||||
expect(getNames()).toEqual(['b', 'a', '']);
|
||||
});
|
||||
|
||||
it('does not sort a column marked as not sortable', () => {
|
||||
const { container } = renderTable({ data: unsortedData });
|
||||
|
||||
fireEvent.click(getHeader(container, '.city-header'));
|
||||
|
||||
expect(getNames()).toEqual(['Charlie', 'Alice', 'Bob']);
|
||||
});
|
||||
|
||||
it('does not sort when the table sortable prop is false', () => {
|
||||
const { container } = renderTable({ data: unsortedData, sortable: false });
|
||||
|
||||
fireEvent.click(getHeader(container, '.name-header'));
|
||||
|
||||
expect(getNames()).toEqual(['Charlie', 'Alice', 'Bob']);
|
||||
});
|
||||
|
||||
it('uses a custom sortFn', () => {
|
||||
const { container } = renderTable({
|
||||
data: [
|
||||
{ name: 'ccc', age: 3 },
|
||||
{ name: 'a', age: 1 },
|
||||
{ name: 'bb', age: 2 },
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
key: 'name',
|
||||
header: { text: 'Name header', class: 'name-header' },
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
sortFn: (a: string, b: string) => a.length - b.length,
|
||||
render: (value: string) => <span data-testid="name-cell">{value}</span>,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(getHeader(container, '.name-header'));
|
||||
|
||||
expect(getNames()).toEqual(['a', 'bb', 'ccc']);
|
||||
});
|
||||
|
||||
it('calls onSortChange with the column key and direction', () => {
|
||||
const { container } = renderTable({
|
||||
data: unsortedData,
|
||||
onSortChange: mocks.onSortChange,
|
||||
});
|
||||
|
||||
fireEvent.click(getHeader(container, '.name-header'));
|
||||
expect(mocks.onSortChange).toHaveBeenLastCalledWith('name', 'asc');
|
||||
|
||||
fireEvent.click(getHeader(container, '.name-header'));
|
||||
expect(mocks.onSortChange).toHaveBeenLastCalledWith('name', 'desc');
|
||||
});
|
||||
|
||||
it('returns to the first page when sorting', () => {
|
||||
const { container } = renderTable({ data: makeRows(15) });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '2' }));
|
||||
expect(getNames()[0]).toBe('row-10');
|
||||
|
||||
fireEvent.click(getHeader(container, '.name-header'));
|
||||
|
||||
expect(getNames()[0]).toBe('row-00');
|
||||
expect(screen.queryByText('row-10')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
it('paginates rows using the default page size', () => {
|
||||
renderTable({ data: makeRows(11) });
|
||||
|
||||
expect(getNames()).toHaveLength(10);
|
||||
expect(screen.queryByText('row-10')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '2' }));
|
||||
|
||||
expect(getNames()).toEqual(['row-10']);
|
||||
});
|
||||
|
||||
it('uses the pageSize prop', () => {
|
||||
renderTable({ data: makeRows(12), pageSize: 5 });
|
||||
|
||||
expect(getNames()).toEqual(['row-00', 'row-01', 'row-02', 'row-03', 'row-04']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '2' }));
|
||||
expect(getNames()).toEqual(['row-05', 'row-06', 'row-07', 'row-08', 'row-09']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3' }));
|
||||
expect(getNames()).toEqual(['row-10', 'row-11']);
|
||||
});
|
||||
|
||||
it('shows all rows and no pagination when pagination is disabled', () => {
|
||||
renderTable({ data: makeRows(11), pagination: false });
|
||||
|
||||
expect(getNames()).toHaveLength(11);
|
||||
expect(screen.queryByRole('button', { name: '2' })).toBeNull();
|
||||
expect(screen.queryByTestId('page-size-select')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the pagination footer when the data fits on one page', () => {
|
||||
renderTable({ data: makeRows(10) });
|
||||
|
||||
expect(screen.queryByRole('button', { name: '2' })).toBeNull();
|
||||
expect(screen.queryByTestId('page-size-select')).toBeNull();
|
||||
});
|
||||
|
||||
it('changes the page size and returns to the first page', () => {
|
||||
renderTable({
|
||||
data: makeRows(12),
|
||||
pageSize: 5,
|
||||
onPageSizeChange: mocks.onPageSizeChange,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '2' }));
|
||||
expect(getNames()[0]).toBe('row-05');
|
||||
|
||||
fireEvent.change(screen.getByTestId('page-size-select'), { target: { value: '10' } });
|
||||
|
||||
expect(mocks.onPageSizeChange).toHaveBeenCalledWith(10);
|
||||
expect(getNames()[0]).toBe('row-00');
|
||||
});
|
||||
|
||||
it('moves to the previous page when the last row of the current page is removed', async () => {
|
||||
const [rows, setRows] = createSignal(makeRows(11));
|
||||
|
||||
render(() => (
|
||||
<Table<Row>
|
||||
data={rows()}
|
||||
columns={columns}
|
||||
pageSize={10}
|
||||
emptyTable={<div>empty</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '2' }));
|
||||
expect(screen.getByText('row-10')).toBeDefined();
|
||||
|
||||
setRows(makeRows(10));
|
||||
|
||||
expect(await screen.findByText('row-09')).toBeDefined();
|
||||
expect(screen.getByText('row-00')).toBeDefined();
|
||||
expect(screen.queryByText('empty')).toBeNull();
|
||||
// The clamp must land on page 0, not on a stale index.
|
||||
expect(screen.queryByRole('button', { name: '2' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('row interactions', () => {
|
||||
it('calls onRowClick with the clicked row', () => {
|
||||
const row = { name: 'Alice', age: 30 };
|
||||
|
||||
renderTable({ data: [row], onRowClick: mocks.onRowClick });
|
||||
|
||||
fireEvent.click(screen.getByText('Alice'));
|
||||
|
||||
expect(mocks.onRowClick).toHaveBeenCalledWith(row);
|
||||
});
|
||||
|
||||
it('renders rows as links when rowLink returns a target', () => {
|
||||
const history = createMemoryHistory();
|
||||
history.set({ value: '/' });
|
||||
|
||||
render(() => (
|
||||
<MemoryRouter history={history}>
|
||||
<Route
|
||||
path="/*"
|
||||
component={() => (
|
||||
<Table<Row>
|
||||
data={[{ name: 'Alice', age: 30 }]}
|
||||
columns={columns}
|
||||
rowLink={() => ({ to: 'DnsRewrites', query: { foo: 'bar' } })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
));
|
||||
|
||||
expect(screen.getByRole('link').getAttribute('href')).toBe('/dns_rewrites?foo=bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout', () => {
|
||||
it('applies custom table, header and row classes', () => {
|
||||
const { container } = renderTable({
|
||||
data: [{ name: 'Alice', age: 30 }],
|
||||
class: 'custom-table',
|
||||
tableHeaderClass: 'custom-header',
|
||||
tableRowClass: 'custom-row',
|
||||
});
|
||||
|
||||
expect(container.querySelector('.custom-table')).not.toBeNull();
|
||||
expect(container.querySelector('.custom-header')).not.toBeNull();
|
||||
expect(container.querySelector('.custom-row')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('applies a custom class to body cells', () => {
|
||||
const { container } = renderTable({ data: [{ name: 'Alice', age: 30 }] });
|
||||
|
||||
expect(container.querySelector('.name-column-cell')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('builds the grid template from column widths', () => {
|
||||
const { container } = renderTable({
|
||||
data: [{ name: 'Alice', age: 30 }],
|
||||
columns: [
|
||||
{ key: 'a', header: { text: 'A' }, accessor: 'name', width: 100 },
|
||||
{ key: 'b', header: { text: 'B' }, accessor: 'age', width: '20%' },
|
||||
{ key: 'c', header: { text: 'C' }, accessor: 'city', fitContent: true },
|
||||
{
|
||||
key: 'd',
|
||||
header: { text: 'D' },
|
||||
accessor: 'name',
|
||||
minWidth: 80,
|
||||
maxWidth: 200,
|
||||
},
|
||||
{ key: 'e', header: { text: 'E' }, accessor: 'age' },
|
||||
],
|
||||
tableHeaderClass: 'grid-header',
|
||||
});
|
||||
|
||||
const header = getHeader(container, '.grid-header');
|
||||
|
||||
expect(header.style.getPropertyValue('--table-columns')).toBe(
|
||||
'100px 20% fit-content(100%) minmax(80px, 200px) minmax(0, 1fr)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
import { render, screen, fireEvent, within, waitFor } from '@solidjs/testing-library';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Cache } from 'panel/components/DnsSettings/Cache';
|
||||
import { CacheInputDialog } from 'panel/components/DnsSettings/Cache/blocks/CacheInputDialog';
|
||||
import { getCacheSizeSummary, getTtlSummary } from 'panel/components/DnsSettings/helpers';
|
||||
import { UINT32_RANGE } from 'panel/helpers/constants';
|
||||
import { dnsConfigState, getDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import { copy, copyInDom, type CopyKey } from 'panel/__tests__/helpers/copy';
|
||||
|
||||
type DnsInfo = {
|
||||
cache_enabled?: boolean;
|
||||
cache_size: number;
|
||||
cache_ttl_min: number;
|
||||
cache_ttl_max: number;
|
||||
cache_optimistic: boolean;
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiDnsInfo: vi.fn(),
|
||||
apiDnsConfig: vi.fn(),
|
||||
apiCacheClear: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/api/generated', () => ({
|
||||
dnsInfo: mocks.apiDnsInfo,
|
||||
dnsConfig: mocks.apiDnsConfig,
|
||||
cacheClear: mocks.apiCacheClear,
|
||||
}));
|
||||
|
||||
vi.mock('panel/stores/toasts', () => ({
|
||||
addErrorToast: vi.fn(),
|
||||
addSuccessToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/common/intl', async () =>
|
||||
(await import('panel/__tests__/helpers/copy')).createIntlMock(),
|
||||
);
|
||||
|
||||
const defaultConfig: DnsInfo = {
|
||||
cache_enabled: false,
|
||||
cache_size: 0,
|
||||
cache_ttl_min: 0,
|
||||
cache_ttl_max: 0,
|
||||
cache_optimistic: false,
|
||||
};
|
||||
|
||||
const renderCache = async (overrides: Partial<DnsInfo> = {}) => {
|
||||
mocks.apiDnsInfo.mockResolvedValue({ ...defaultConfig, ...overrides });
|
||||
|
||||
await getDnsConfig();
|
||||
render(() => <Cache />);
|
||||
};
|
||||
|
||||
const getRow = (title: CopyKey) => {
|
||||
const label = screen
|
||||
.getAllByText(copyInDom(title))
|
||||
.find((element) => !element.closest('[role="dialog"]'));
|
||||
|
||||
if (!label) {
|
||||
throw new Error(`Cache row not found: ${title}`);
|
||||
}
|
||||
|
||||
const row = label.closest('[role="button"]');
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
throw new Error(`Cache row is not an element: ${title}`);
|
||||
}
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
const getDialog = (title: CopyKey) => {
|
||||
const dialogTitle = `${title}_title` as CopyKey;
|
||||
const label = screen
|
||||
.getAllByText(copyInDom(dialogTitle))
|
||||
.find((element) => element.closest('[role="dialog"]'));
|
||||
|
||||
if (!label) {
|
||||
throw new Error(`Cache dialog title not found: ${title}`);
|
||||
}
|
||||
|
||||
const dialog = label.closest('[role="dialog"]');
|
||||
if (!(dialog instanceof HTMLElement)) {
|
||||
throw new Error(`Cache dialog is not an element: ${title}`);
|
||||
}
|
||||
|
||||
return dialog;
|
||||
};
|
||||
|
||||
const getSwitch = (id: string) => {
|
||||
const input = document.querySelector(`input#${id}`);
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
throw new Error(`Cache switch not found: ${id}`);
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
const openDialog = async (title: CopyKey) => {
|
||||
fireEvent.click(getRow(title));
|
||||
|
||||
const dialog = getDialog(title);
|
||||
const input = await within(dialog).findByRole('spinbutton');
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
throw new Error(`Cache dialog input not found: ${title}`);
|
||||
}
|
||||
|
||||
return { dialog, input };
|
||||
};
|
||||
|
||||
const saveDialog = (dialog: HTMLElement) => {
|
||||
fireEvent.click(within(dialog).getByTestId('config-dialog-save'));
|
||||
};
|
||||
|
||||
const waitForSave = () => waitFor(() => expect(dnsConfigState.processingSetConfig).toBe(false));
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe('Cache', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.apiDnsConfig.mockResolvedValue(undefined);
|
||||
mocks.apiCacheClear.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the cache settings and their current values', async () => {
|
||||
await renderCache({
|
||||
cache_enabled: true,
|
||||
cache_size: 4194304,
|
||||
cache_ttl_min: 60,
|
||||
cache_ttl_max: 3600,
|
||||
cache_optimistic: true,
|
||||
});
|
||||
|
||||
expect(screen.getByText(copyInDom('dns_cache_title'))).toBeDefined();
|
||||
expect(getRow('dns_cache_size')).toBeDefined();
|
||||
expect(screen.getByText('4 MB')).toBeDefined();
|
||||
expect(screen.getByText(copyInDom('dns_ttl_value', { value: 60 }))).toBeDefined();
|
||||
expect(screen.getByText(copyInDom('dns_ttl_value', { value: 3600 }))).toBeDefined();
|
||||
expect(getSwitch('cache_enabled').checked).toBe(true);
|
||||
expect(getSwitch('optimistic_caching').checked).toBe(true);
|
||||
});
|
||||
|
||||
it('disables optimistic caching while the cache is disabled', async () => {
|
||||
await renderCache({ cache_enabled: false });
|
||||
|
||||
expect(getSwitch('optimistic_caching').disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('enables optimistic caching when the cache is enabled', async () => {
|
||||
await renderCache({ cache_enabled: true });
|
||||
|
||||
expect(getSwitch('optimistic_caching').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('shows a zero cache size summary', async () => {
|
||||
await renderCache({ cache_size: 0 });
|
||||
|
||||
expect(screen.getByText('0 bytes')).toBeDefined();
|
||||
});
|
||||
|
||||
it('disables the cache size row while the cache state is unknown', async () => {
|
||||
await renderCache({ cache_enabled: undefined });
|
||||
|
||||
expect(getRow('dns_cache_size')).toHaveAttribute('tabindex', '-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('value rows', () => {
|
||||
it.each(['dns_cache_size', 'dns_override_min_ttl', 'dns_override_max_ttl'] as const)(
|
||||
'allows editing %s while the cache is disabled',
|
||||
async (title) => {
|
||||
await renderCache();
|
||||
|
||||
const row = getRow(title);
|
||||
expect(row).toHaveAttribute('tabindex', '0');
|
||||
|
||||
fireEvent.click(row);
|
||||
|
||||
const dialog = getDialog(title);
|
||||
expect(await within(dialog).findByRole('spinbutton')).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('prefills the dialog with the stored value', async () => {
|
||||
await renderCache({ cache_size: 1048576 });
|
||||
|
||||
const { input } = await openDialog('dns_cache_size');
|
||||
|
||||
await waitFor(() => expect(input.value).toBe('1048576'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('saving values', () => {
|
||||
it('keeps the cache switch state when saving the size while the cache is disabled', async () => {
|
||||
await renderCache({ cache_enabled: false });
|
||||
|
||||
const { dialog, input } = await openDialog('dns_cache_size');
|
||||
|
||||
fireEvent.change(input, { target: { value: '4194304' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({
|
||||
cache_size: 4194304,
|
||||
cache_enabled: false,
|
||||
}),
|
||||
);
|
||||
await waitForSave();
|
||||
});
|
||||
|
||||
it('keeps the cache switch state when saving the size while the cache is enabled', async () => {
|
||||
await renderCache({ cache_enabled: true, cache_size: 1024 });
|
||||
|
||||
const { dialog, input } = await openDialog('dns_cache_size');
|
||||
|
||||
fireEvent.change(input, { target: { value: '1048576' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({
|
||||
cache_size: 1048576,
|
||||
cache_enabled: true,
|
||||
}),
|
||||
);
|
||||
await waitForSave();
|
||||
});
|
||||
|
||||
it('omits cache_enabled when it is unknown', async () => {
|
||||
mocks.apiDnsInfo.mockResolvedValue({
|
||||
cache_enabled: undefined,
|
||||
cache_size: 0,
|
||||
cache_ttl_min: 0,
|
||||
cache_ttl_max: 0,
|
||||
cache_optimistic: false,
|
||||
});
|
||||
|
||||
await getDnsConfig();
|
||||
render(() => (
|
||||
<CacheInputDialog
|
||||
configKey="cache_size"
|
||||
open={() => true}
|
||||
onClose={vi.fn()}
|
||||
processing={false}
|
||||
/>
|
||||
));
|
||||
|
||||
const dialog = getDialog('dns_cache_size');
|
||||
const input = await within(dialog).findByRole('spinbutton');
|
||||
|
||||
fireEvent.change(input, { target: { value: '1048576' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_size: 1048576 }),
|
||||
);
|
||||
await waitForSave();
|
||||
});
|
||||
|
||||
it('saves the minimum TTL override', async () => {
|
||||
await renderCache({ cache_ttl_min: 0, cache_ttl_max: 3600 });
|
||||
|
||||
const { dialog, input } = await openDialog('dns_override_min_ttl');
|
||||
|
||||
fireEvent.change(input, { target: { value: '60' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_ttl_min: 60 }),
|
||||
);
|
||||
await waitForSave();
|
||||
});
|
||||
|
||||
it('saves the maximum TTL override', async () => {
|
||||
await renderCache({ cache_ttl_min: 0, cache_ttl_max: 0 });
|
||||
|
||||
const { dialog, input } = await openDialog('dns_override_max_ttl');
|
||||
|
||||
fireEvent.change(input, { target: { value: '3600' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_ttl_max: 3600 }),
|
||||
);
|
||||
await waitForSave();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('rejects an empty cache size', async () => {
|
||||
await renderCache({ cache_size: 1024 });
|
||||
|
||||
const { dialog, input } = await openDialog('dns_cache_size');
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
|
||||
expect(within(dialog).getByText(copyInDom('form_error_required'))).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a zero cache size while the cache is enabled', async () => {
|
||||
await renderCache({ cache_enabled: true, cache_size: 1024 });
|
||||
|
||||
const { dialog, input } = await openDialog('dns_cache_size');
|
||||
|
||||
fireEvent.change(input, { target: { value: '0' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(dialog).getByText(copyInDom('cache_config_size_validation')),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('allows a zero cache size while the cache is disabled', async () => {
|
||||
await renderCache({ cache_enabled: false, cache_size: 1024 });
|
||||
|
||||
const { dialog, input } = await openDialog('dns_cache_size');
|
||||
|
||||
fireEvent.change(input, { target: { value: '0' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({
|
||||
cache_size: 0,
|
||||
cache_enabled: false,
|
||||
}),
|
||||
);
|
||||
await waitForSave();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'rejects a fractional cache size when cache_enabled is %s',
|
||||
async (enabled) => {
|
||||
await renderCache({ cache_enabled: enabled, cache_size: 1024 });
|
||||
|
||||
const { dialog, input } = await openDialog('dns_cache_size');
|
||||
|
||||
fireEvent.change(input, { target: { value: '0.5' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
copyInDom('form_value_value_from_error', {
|
||||
min_value: UINT32_RANGE.MIN.toLocaleString(),
|
||||
max_value: UINT32_RANGE.MAX.toLocaleString(),
|
||||
}),
|
||||
),
|
||||
).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['dns_override_min_ttl', 'dns_override_max_ttl'] as const)(
|
||||
'rejects a fractional %s value',
|
||||
async (title) => {
|
||||
await renderCache();
|
||||
|
||||
const { dialog, input } = await openDialog(title);
|
||||
|
||||
fireEvent.change(input, { target: { value: '0.5' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
copyInDom('form_value_value_from_error', {
|
||||
min_value: UINT32_RANGE.MIN.toLocaleString(),
|
||||
max_value: UINT32_RANGE.MAX.toLocaleString(),
|
||||
}),
|
||||
),
|
||||
).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a cache size above the uint32 range', async () => {
|
||||
await renderCache({ cache_size: 1024 });
|
||||
|
||||
const { dialog, input } = await openDialog('dns_cache_size');
|
||||
|
||||
fireEvent.change(input, { target: { value: '4294967296' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
copyInDom('form_value_value_from_error', {
|
||||
min_value: UINT32_RANGE.MIN.toLocaleString(),
|
||||
max_value: UINT32_RANGE.MAX.toLocaleString(),
|
||||
}),
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a minimum TTL above the maximum', async () => {
|
||||
await renderCache({ cache_ttl_min: 0, cache_ttl_max: 100 });
|
||||
|
||||
const { dialog, input } = await openDialog('dns_override_min_ttl');
|
||||
|
||||
fireEvent.change(input, { target: { value: '200' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(dialog).getByText(copyInDom('cache_config_ttl_validation')),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a maximum TTL below the minimum', async () => {
|
||||
await renderCache({ cache_ttl_min: 100, cache_ttl_max: 200 });
|
||||
|
||||
const { dialog, input } = await openDialog('dns_override_max_ttl');
|
||||
|
||||
fireEvent.change(input, { target: { value: '50' } });
|
||||
saveDialog(dialog);
|
||||
|
||||
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(dialog).getByText(copyInDom('cache_config_ttl_validation')),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('switches', () => {
|
||||
it('enables the cache', async () => {
|
||||
await renderCache({ cache_enabled: false });
|
||||
|
||||
fireEvent.click(getSwitch('cache_enabled'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_enabled: true }),
|
||||
);
|
||||
await waitForSave();
|
||||
});
|
||||
|
||||
it('disables the cache', async () => {
|
||||
await renderCache({ cache_enabled: true });
|
||||
|
||||
fireEvent.click(getSwitch('cache_enabled'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_enabled: false }),
|
||||
);
|
||||
await waitForSave();
|
||||
});
|
||||
|
||||
it('enables optimistic caching', async () => {
|
||||
await renderCache({ cache_enabled: true, cache_optimistic: false });
|
||||
|
||||
fireEvent.click(getSwitch('optimistic_caching'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_optimistic: true }),
|
||||
);
|
||||
await waitForSave();
|
||||
});
|
||||
|
||||
it('reverts the cache switch when saving fails', async () => {
|
||||
mocks.apiDnsConfig.mockRejectedValueOnce(new Error('failed'));
|
||||
await renderCache({ cache_enabled: false });
|
||||
|
||||
const input = getSwitch('cache_enabled');
|
||||
fireEvent.click(input);
|
||||
|
||||
expect(input.checked).toBe(true);
|
||||
await waitFor(() => expect(input.checked).toBe(false));
|
||||
});
|
||||
|
||||
it('ignores a second toggle while a save is in flight', async () => {
|
||||
const pending = deferred<void>();
|
||||
mocks.apiDnsConfig.mockReturnValueOnce(pending.promise);
|
||||
await renderCache({ cache_enabled: false });
|
||||
|
||||
const input = getSwitch('cache_enabled');
|
||||
fireEvent.click(input);
|
||||
fireEvent.click(input);
|
||||
|
||||
expect(mocks.apiDnsConfig).toHaveBeenCalledTimes(1);
|
||||
|
||||
pending.resolve();
|
||||
await waitFor(() => expect(dnsConfigState.processingSetConfig).toBe(false));
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear cache', () => {
|
||||
it('clears the cache after confirmation', async () => {
|
||||
await renderCache();
|
||||
|
||||
fireEvent.click(screen.getByText(copyInDom('dns_clear_cache')));
|
||||
fireEvent.click(screen.getByText(copyInDom('dns_clear_cache_confirm')));
|
||||
|
||||
expect(mocks.apiCacheClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not clear the cache when cancelled', async () => {
|
||||
await renderCache();
|
||||
|
||||
fireEvent.click(screen.getByText(copyInDom('dns_clear_cache')));
|
||||
fireEvent.click(screen.getByText(copyInDom('cancel')));
|
||||
|
||||
expect(mocks.apiCacheClear).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache summaries', () => {
|
||||
it.each([
|
||||
[0, '0 bytes'],
|
||||
[512, '512 bytes'],
|
||||
[2048, '2 KB'],
|
||||
[1048576, '1 MB'],
|
||||
[4194304, '4 MB'],
|
||||
])('formats %i bytes as %s', (bytes, expected) => {
|
||||
expect(getCacheSizeSummary(bytes)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns an empty cache size summary for missing values', () => {
|
||||
expect(getCacheSizeSummary(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('formats the TTL summary', () => {
|
||||
expect(getTtlSummary(60)).toBe(copy('dns_ttl_value', { value: 60 }));
|
||||
});
|
||||
|
||||
it('returns an empty TTL summary for missing values', () => {
|
||||
expect(getTtlSummary(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -145,8 +145,7 @@ export const Menu = (props: Props) => {
|
||||
<div class={cn(s.menuLinkWrapper)}>
|
||||
<a
|
||||
href={getLogoutUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rel="external"
|
||||
class={s.menuLink}
|
||||
id="sign_out"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type JSX, createMemo, For, Show, untrack } from 'solid-js';
|
||||
import { type JSX, createEffect, createMemo, For, Show, untrack } from 'solid-js';
|
||||
import { createStore } from 'solid-js/store';
|
||||
import cn from 'clsx';
|
||||
|
||||
@@ -137,6 +137,18 @@ export const Table = <T extends Record<string, any>>(props: TableProps<T>) => {
|
||||
|
||||
const totalPages = () => Math.ceil(sortedData().length / state.pageSize);
|
||||
|
||||
// Keep the current page in range when rows are deleted from the last page.
|
||||
createEffect(() => {
|
||||
if (props.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastPage = Math.max(0, totalPages() - 1);
|
||||
if (state.currentPage > lastPage) {
|
||||
setState('currentPage', lastPage);
|
||||
}
|
||||
});
|
||||
|
||||
const tableGridTemplate = createMemo(() =>
|
||||
props.columns
|
||||
.map((column) => {
|
||||
|
||||
@@ -71,7 +71,9 @@ export const Cache = () => {
|
||||
title={intl.getMessage('dns_cache_size')}
|
||||
description={intl.getMessage('dns_cache_size_desc')}
|
||||
value={cacheSizeValue()}
|
||||
disabled={!dnsConfigState.cache_enabled}
|
||||
// Saving a size without a known switch state would make the
|
||||
// backend infer it and silently change the cache state.
|
||||
disabled={dnsConfigState.cache_enabled === undefined}
|
||||
onClick={cacheSizeDialog.openDialog}
|
||||
/>
|
||||
|
||||
@@ -81,7 +83,6 @@ export const Cache = () => {
|
||||
title={intl.getMessage('dns_override_min_ttl')}
|
||||
description={intl.getMessage('dns_override_min_ttl_desc')}
|
||||
value={minTtlValue()}
|
||||
disabled={!dnsConfigState.cache_enabled}
|
||||
onClick={minTtlDialog.openDialog}
|
||||
/>
|
||||
|
||||
@@ -91,7 +92,6 @@ export const Cache = () => {
|
||||
title={intl.getMessage('dns_override_max_ttl')}
|
||||
description={intl.getMessage('dns_override_max_ttl_desc')}
|
||||
value={maxTtlValue()}
|
||||
disabled={!dnsConfigState.cache_enabled}
|
||||
onClick={maxTtlDialog.openDialog}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { UINT32_RANGE } from 'panel/helpers/constants';
|
||||
import { validateBetween, validateRequiredValue } from 'panel/helpers/validators';
|
||||
import {
|
||||
validateBetween,
|
||||
validateCacheSize,
|
||||
validateRequiredValue,
|
||||
} from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
@@ -33,7 +37,8 @@ const CACHE_DIALOG_CONFIG: Record<
|
||||
label: () => intl.getMessage('dns_cache_size_label'),
|
||||
validate: (v) =>
|
||||
validateRequiredValue(v) ||
|
||||
validateBetween(Number(v), UINT32_RANGE.MIN, UINT32_RANGE.MAX) ||
|
||||
validateBetween(Number(v), UINT32_RANGE.MIN, UINT32_RANGE.MAX, { integer: true }) ||
|
||||
validateCacheSize(Number(v), !!dnsConfigState.cache_enabled) ||
|
||||
'',
|
||||
},
|
||||
cache_ttl_min: {
|
||||
@@ -45,7 +50,9 @@ const CACHE_DIALOG_CONFIG: Record<
|
||||
const requiredErr = validateRequiredValue(v);
|
||||
if (requiredErr) return requiredErr;
|
||||
const num = Number(v);
|
||||
const rangeErr = validateBetween(num, UINT32_RANGE.MIN, UINT32_RANGE.MAX);
|
||||
const rangeErr = validateBetween(num, UINT32_RANGE.MIN, UINT32_RANGE.MAX, {
|
||||
integer: true,
|
||||
});
|
||||
if (rangeErr) return rangeErr;
|
||||
// Cross-field TTL check: min > max. Only the maximum used to run
|
||||
// this check, so a minimum above the stored maximum was accepted,
|
||||
@@ -66,7 +73,9 @@ const CACHE_DIALOG_CONFIG: Record<
|
||||
const requiredErr = validateRequiredValue(v);
|
||||
if (requiredErr) return requiredErr;
|
||||
const num = Number(v);
|
||||
const rangeErr = validateBetween(num, UINT32_RANGE.MIN, UINT32_RANGE.MAX);
|
||||
const rangeErr = validateBetween(num, UINT32_RANGE.MIN, UINT32_RANGE.MAX, {
|
||||
integer: true,
|
||||
});
|
||||
if (rangeErr) return rangeErr;
|
||||
// Cross-field TTL check: min > max
|
||||
const minVal = Number(dnsConfigState.cache_ttl_min);
|
||||
@@ -96,7 +105,21 @@ export const CacheInputDialog = (props: Props) => {
|
||||
onSubmit={() => {
|
||||
const configKey = props.configKey;
|
||||
field.submitIfValid((v) => {
|
||||
setDnsConfig({ [configKey]: v === '' ? 0 : Number(v) });
|
||||
const value = v === '' ? 0 : Number(v);
|
||||
const cacheEnabled = dnsConfigState.cache_enabled;
|
||||
|
||||
// The backend infers `cache_enabled` from `cache_size` when omitted, so send
|
||||
// the known flag to keep the switch in sync, and omit it when it is unknown.
|
||||
setDnsConfig(
|
||||
configKey === 'cache_size'
|
||||
? {
|
||||
cache_size: value,
|
||||
...(cacheEnabled !== undefined
|
||||
? { cache_enabled: cacheEnabled }
|
||||
: {}),
|
||||
}
|
||||
: { [configKey]: value },
|
||||
);
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -328,10 +328,7 @@ export const validateClientsPerLine = (value: string): string | undefined =>
|
||||
validatePerLine(
|
||||
value,
|
||||
(line) =>
|
||||
R_IPV4.test(line) ||
|
||||
isValidIpv6(line) ||
|
||||
isValidCidr(line) ||
|
||||
R_CLIENT_ID.test(line),
|
||||
R_IPV4.test(line) || isValidIpv6(line) || isValidCidr(line) || R_CLIENT_ID.test(line),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -779,8 +776,22 @@ export const validateHostnameNotDuplicate =
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const validateBetween = (value: number, min: number, max: number): string | undefined => {
|
||||
if (value < min || value > max) {
|
||||
/**
|
||||
* Validates that a value is within the inclusive range. When `integer` is
|
||||
* true, non-integer values are rejected as well.
|
||||
*/
|
||||
export const validateBetween = (
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
options?: { integer?: boolean },
|
||||
): string | undefined => {
|
||||
if (
|
||||
!Number.isFinite(value) ||
|
||||
value < min ||
|
||||
value > max ||
|
||||
(options?.integer && !Number.isInteger(value))
|
||||
) {
|
||||
return intl.getMessage('form_value_value_from_error', {
|
||||
min_value: min.toLocaleString(),
|
||||
max_value: max.toLocaleString(),
|
||||
@@ -826,18 +837,19 @@ export const validateMinValue = (value: number, min: number): string | undefined
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the client DNS cache size.
|
||||
* Callers should gate on `upstreams_cache_enabled` before invoking.
|
||||
* Returns undefined if valid, or an i18n error message string if invalid.
|
||||
* Validates a DNS cache size. The value is only checked when `enabled` is
|
||||
* true: it must be greater than zero and within the uint32 range.
|
||||
*
|
||||
* Used for both the per-client upstream cache and the global DNS cache.
|
||||
*
|
||||
* @param size - The cache size in bytes
|
||||
* @param enabled - Whether per-client upstream caching is enabled
|
||||
* @param enabled - Whether the cache the size belongs to is enabled
|
||||
*
|
||||
* @example validateCacheSize(0, true) // error ("must be greater than zero")
|
||||
* @example validateCacheSize('', true) // error ("must be greater than zero")
|
||||
* @example validateCacheSize(1000, true) // undefined (valid)
|
||||
* @example validateCacheSize(0, true) // error ("must be greater than zero")
|
||||
* @example validateCacheSize('', true) // error ("must be greater than zero")
|
||||
* @example validateCacheSize(1000, true) // undefined (valid)
|
||||
* @example validateCacheSize(4294967296, true) // error (exceeds UINT32_MAX)
|
||||
* @example validateCacheSize(0, false) // undefined (no validation when disabled)
|
||||
* @example validateCacheSize(0, false) // undefined (no validation when disabled)
|
||||
*/
|
||||
export const validateCacheSize = (size: number | string, enabled: boolean): ValidationResult => {
|
||||
if (!enabled) {
|
||||
@@ -847,5 +859,5 @@ export const validateCacheSize = (size: number | string, enabled: boolean): Vali
|
||||
if (Number.isNaN(num) || num === 0) {
|
||||
return intl.getMessage('cache_config_size_validation');
|
||||
}
|
||||
return validateBetween(num, 1, UINT32_RANGE.MAX);
|
||||
return validateBetween(num, 1, UINT32_RANGE.MAX, { integer: true });
|
||||
};
|
||||
Reference in new issue
Block a user