Skip to content

Commit 87d8b97

Browse files
authored
feat #162: live feed button (#173)
1 parent 279e36e commit 87d8b97

File tree

13 files changed

+662
-338
lines changed

13 files changed

+662
-338
lines changed

src/Serilog.Ui.Web/.prettierrc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ trailingComma: all
66
printWidth: 90
77

88
plugins:
9-
- "prettier-plugin-organize-imports"
9+
- 'prettier-plugin-organize-imports'

src/Serilog.Ui.Web/package.json

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "3.1.0",
2+
"version": "3.2.0",
33
"name": "serilog-ui",
44
"private": true,
55
"type": "module",
@@ -15,41 +15,41 @@
1515
},
1616
"dependencies": {
1717
"@fontsource/mononoki": "^5.2.5",
18-
"@mantine/core": "^7.17.2",
19-
"@mantine/dates": "^7.17.2",
20-
"@mantine/hooks": "^7.17.2",
21-
"@mantine/notifications": "^7.17.2",
18+
"@mantine/core": "^7.17.3",
19+
"@mantine/dates": "^7.17.3",
20+
"@mantine/hooks": "^7.17.3",
21+
"@mantine/notifications": "^7.17.3",
2222
"@tabler/icons-react": "^3.31.0",
23-
"@tanstack/react-query": "^5.68.0",
23+
"@tanstack/react-query": "^5.71.10",
2424
"dayjs": "^1.11.13",
2525
"jose": "^6.0.10",
2626
"react": "^18.3.1",
2727
"react-dom": "^18.3.1",
28-
"react-hook-form": "^7.54.2",
29-
"react-router": "^7.3.0",
30-
"xml-formatter": "^3.6.4"
28+
"react-hook-form": "^7.55.0",
29+
"react-router": "^7.5.0",
30+
"xml-formatter": "^3.6.5"
3131
},
3232
"devDependencies": {
3333
"@faker-js/faker": "^9.6.0",
3434
"@testing-library/dom": "^10.4.0",
3535
"@testing-library/jest-dom": "^6.6.3",
36-
"@testing-library/react": "^16.2.0",
36+
"@testing-library/react": "^16.3.0",
3737
"@testing-library/user-event": "^14.6.1",
38-
"@types/node": "^22.13.10",
38+
"@types/node": "^22.14.0",
3939
"@types/react": "^18.3.11",
4040
"@types/react-dom": "^18.3.0",
41-
"@vitejs/plugin-react-swc": "^3.8.0",
42-
"@vitest/coverage-istanbul": "^3.0.8",
43-
"@vitest/ui": "^3.0.8",
41+
"@vitejs/plugin-react-swc": "^3.8.1",
42+
"@vitest/coverage-istanbul": "^3.1.1",
43+
"@vitest/ui": "^3.1.1",
4444
"@welldone-software/why-did-you-render": "^10.0.1",
4545
"eslint": "^8.57.0",
4646
"eslint-config-prettier": "^10.1.1",
4747
"eslint-plugin-html": "^8.1.2",
4848
"eslint-plugin-import": "^2.31.0",
4949
"eslint-plugin-jsx-a11y": "^6.10.2",
50-
"eslint-plugin-prettier": "^5.2.3",
50+
"eslint-plugin-prettier": "^5.2.6",
5151
"eslint-plugin-promise": "^7.2.1",
52-
"eslint-plugin-react": "^7.37.4",
52+
"eslint-plugin-react": "^7.37.5",
5353
"eslint-plugin-react-hooks": "^5.2.0",
5454
"eslint-plugin-testing-library": "^7.1.1",
5555
"eslint-plugin-vitest": "^0.5.4",
@@ -63,13 +63,13 @@
6363
"prettier-plugin-organize-imports": "^4.1.0",
6464
"shiki": "^3.2.1",
6565
"testing-library-selector": "^0.3.1",
66-
"typescript": "^5.8.2",
67-
"typescript-eslint": "^8.26.1",
66+
"typescript": "^5.8.3",
67+
"typescript-eslint": "^8.29.0",
6868
"vite": "^6.2.5",
69-
"vite-plugin-checker": "^0.9.0",
69+
"vite-plugin-checker": "^0.9.1",
7070
"vite-plugin-mkcert": "^1.17.8",
7171
"vite-tsconfig-paths": "^5.1.4",
72-
"vitest": "^3.0.8",
72+
"vitest": "^3.1.1",
7373
"vitest-sonar-reporter": "^2.0.0"
7474
},
7575
"engines": {

src/Serilog.Ui.Web/src/__tests__/_setup/mocks/fetch.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import dayjs from 'dayjs';
22
import { http, HttpResponse } from 'msw';
3+
import { defaultAuthType } from '../../../app/hooks/useSerilogUiProps.tsx';
34
import {
45
AuthType,
56
EncodedSeriLogObject,
@@ -9,7 +10,6 @@ import {
910
SortPropertyOptions,
1011
} from '../../../types/types';
1112
import { dbKeysMock, fakeLogs, fakeLogs2ndTable, fakeLogs3rdTable } from './samples';
12-
import { defaultAuthType } from '../../../app/hooks/useSerilogUiProps.tsx';
1313

1414
export const developmentListenersHost = ['https://localhost:3001'];
1515

@@ -51,7 +51,9 @@ export const handlers = developmentListenersHost.flatMap((dlh) => [
5151
http.get(`${dlh}/api/keys`, ({ request }) => {
5252
const auth = request.headers.get('authorization');
5353

54-
return defaultAuthType !== AuthType.Custom && !auth ? HttpResponse.error() : HttpResponse.json(dbKeysMock);
54+
return defaultAuthType !== AuthType.Custom && !auth
55+
? HttpResponse.error()
56+
: HttpResponse.json(dbKeysMock);
5557
}),
5658
]);
5759

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { act, render, screen, userEvent } from '__tests__/_setup/testing-utils';
2+
import { RefreshButton } from 'app/components/Refresh/RefreshButton';
3+
import { liveRefreshOptions } from 'app/hooks/useLiveRefresh';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
vi.mock('react-hook-form', async () => {
7+
const actual =
8+
await vi.importActual<typeof import('react-hook-form')>('react-hook-form');
9+
10+
return { ...actual, useWatch: () => 'page' };
11+
});
12+
13+
const headers = () => {
14+
const head = new Headers();
15+
head.append('authorization', 'test');
16+
return head;
17+
};
18+
vi.mock('../../../app/hooks/useAuthProperties', () => ({
19+
useAuthProperties: () => ({
20+
fetchInfo: {
21+
headers: { headers: headers() },
22+
routePrefix: '',
23+
},
24+
isHeaderReady: true,
25+
}),
26+
}));
27+
28+
describe('RefreshButton', () => {
29+
beforeEach(() => {
30+
vi.useFakeTimers({ shouldAdvanceTime: true });
31+
});
32+
afterEach(vi.useRealTimers);
33+
34+
it('renders', async () => {
35+
render(<RefreshButton />);
36+
37+
const durationSelector = screen.getByLabelText('refresh-duration-selector');
38+
expect(durationSelector).toBeInTheDocument();
39+
40+
await userEvent.click(durationSelector);
41+
await act(vi.advanceTimersToNextTimerAsync);
42+
43+
const times = liveRefreshOptions.map((lro) => lro.value);
44+
times.forEach((time) => {
45+
expect(screen.getByLabelText('refresh-duration-' + time)).toBeInTheDocument();
46+
});
47+
});
48+
49+
it('runs live feed activies with refetch sample', async () => {
50+
const spy = vi.spyOn(global, 'fetch');
51+
render(<RefreshButton />);
52+
53+
const durationSelector = screen.getByLabelText('refresh-duration-selector');
54+
await userEvent.click(durationSelector);
55+
await act(vi.advanceTimersToNextTimerAsync);
56+
57+
const sampleOpt = liveRefreshOptions[5];
58+
const timeSelector = screen.getByLabelText('refresh-duration-' + sampleOpt.value);
59+
await userEvent.click(timeSelector);
60+
await act(vi.advanceTimersToNextTimerAsync);
61+
62+
expect(spy).toHaveBeenCalledOnce();
63+
64+
await act(async () => {
65+
await vi.advanceTimersByTimeAsync(1000 * 300 + 1);
66+
});
67+
expect(spy).toHaveBeenCalledTimes(2);
68+
69+
const durationStopper = screen.getByLabelText('refresh-duration-cancel-button');
70+
expect(screen.queryByLabelText('refresh-duration-selector')).not.toBeInTheDocument();
71+
expect(durationStopper).toBeInTheDocument();
72+
73+
await userEvent.click(durationStopper);
74+
await act(vi.advanceTimersToNextTimerAsync);
75+
76+
expect(
77+
screen.queryByLabelText('refresh-duration-cancel-button'),
78+
).not.toBeInTheDocument();
79+
expect(screen.getByLabelText('refresh-duration-selector')).toBeInTheDocument();
80+
});
81+
});

src/Serilog.Ui.Web/src/__tests__/components/ShellStructure/Header.spec.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ describe('Header', () => {
2323
await waitFor(() => {
2424
expect(screen.getByRole('button', { name: 'Filter' })).toBeInTheDocument();
2525
});
26-
await waitFor(() => {
27-
expect(screen.getByText('Serilog UI')).toBeInTheDocument();
28-
});
26+
27+
expect(screen.getByText('Serilog UI')).toBeInTheDocument();
28+
expect(screen.getByLabelText('refresh-duration-selector')).toBeInTheDocument();
2929
});
3030
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { act, renderHook } from '__tests__/_setup/testing-utils';
2+
import { describe, expect, it } from 'vitest';
3+
import { useLiveRefresh } from '../../app/hooks/useLiveRefresh';
4+
5+
describe('useLiveRefresh', () => {
6+
it('returns base properties', () => {
7+
const { result } = renderHook(() => useLiveRefresh());
8+
9+
expect(result.current.isLiveRefreshRunning).toBeFalsy();
10+
expect(result.current.liveRefreshLabel).toBe('');
11+
expect(result.current.refetchInterval).toBe(0);
12+
});
13+
14+
it.each([
15+
{ refetch: 5000, label: '5s', time: 'five' },
16+
{ refetch: 15000, label: '15s', time: 'fifteen' },
17+
{ refetch: 30000, label: '30s', time: 'thirty' },
18+
{ refetch: 60000, label: '1m', time: 'sixty' },
19+
{ refetch: 120000, label: '2m', time: 'onehundredtwenty' },
20+
{ refetch: 300000, label: '5m', time: 'threehundred' },
21+
{ refetch: 900000, label: '15m', time: 'ninehundred' },
22+
])('starts fetch interval', ({ label, refetch, time }) => {
23+
const { result } = renderHook(() => useLiveRefresh());
24+
25+
act(() => {
26+
result.current.startLiveRefresh(time);
27+
});
28+
29+
expect(result.current.isLiveRefreshRunning).toBeTruthy();
30+
expect(result.current.liveRefreshLabel).toBe(label);
31+
expect(result.current.refetchInterval).toBe(refetch);
32+
});
33+
34+
it('stops fetch interval', () => {
35+
const { result } = renderHook(() => useLiveRefresh());
36+
37+
act(() => {
38+
result.current.startLiveRefresh('five');
39+
});
40+
expect(result.current.refetchInterval).toBe(5000);
41+
42+
act(() => {
43+
result.current.stopLiveRefresh();
44+
});
45+
46+
expect(result.current.isLiveRefreshRunning).toBeFalsy();
47+
expect(result.current.refetchInterval).toBe(0);
48+
});
49+
50+
it('does not activate on invalid time', () => {
51+
const { result } = renderHook(() => useLiveRefresh());
52+
53+
act(() => {
54+
result.current.startLiveRefresh('five');
55+
});
56+
expect(result.current.refetchInterval).toBe(5000);
57+
58+
act(() => {
59+
result.current.startLiveRefresh(null);
60+
});
61+
62+
expect(result.current.isLiveRefreshRunning).toBeFalsy();
63+
expect(result.current.refetchInterval).toBe(0);
64+
});
65+
66+
it('set activation time to 0 on unexpected time', () => {
67+
const { result } = renderHook(() => useLiveRefresh());
68+
69+
act(() => {
70+
result.current.startLiveRefresh('uhm');
71+
});
72+
73+
expect(result.current.isLiveRefreshRunning).toBeFalsy();
74+
expect(result.current.refetchInterval).toBe(0);
75+
});
76+
});

src/Serilog.Ui.Web/src/app/components/Authorization/AuthorizeButton.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useDisclosure } from '@mantine/hooks';
33
import { IconLockCheck, IconLockOpen } from '@tabler/icons-react';
44
import { useSerilogUiProps } from 'app/hooks/useSerilogUiProps';
55
import { lazy, memo, Suspense } from 'react';
6+
import { theme } from 'style/theme';
67
import { AuthType } from 'types/types';
78
import { useAuthProperties } from '../../hooks/useAuthProperties';
89

@@ -18,7 +19,7 @@ const AuthorizeButton = () => {
1819

1920
return (
2021
<>
21-
<Button color="green" size="compact-md" onClick={open}>
22+
<Button color={theme.colors?.green?.[7]} size="compact-md" onClick={open}>
2223
{isHeaderReady ? <IconClose /> : <IconOpen />}
2324
Authorize
2425
</Button>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Button, Popover, Tooltip } from '@mantine/core';
2+
import { IconRefresh } from '@tabler/icons-react';
3+
import { liveRefreshOptions } from 'app/hooks/useLiveRefresh';
4+
import useQueryLogs from 'app/hooks/useQueryLogs';
5+
import classes from 'style/search.module.css';
6+
import { theme } from 'style/theme';
7+
8+
export const RefreshButton = () => {
9+
const { isLiveRefreshRunning, liveRefreshLabel, startLiveRefresh, stopLiveRefresh } =
10+
useQueryLogs();
11+
12+
if (isLiveRefreshRunning)
13+
return (
14+
<Tooltip label="Stop live refresh">
15+
<Button
16+
fz={9}
17+
size="compact-md"
18+
aria-label="refresh-duration-cancel-button"
19+
color={theme.colors?.green?.[7]}
20+
onClick={stopLiveRefresh}
21+
className={classes.refreshButton}
22+
>
23+
{liveRefreshLabel}
24+
</Button>
25+
</Tooltip>
26+
);
27+
28+
return (
29+
<Popover width={105} trapFocus position="bottom" withArrow shadow="md">
30+
<Popover.Target>
31+
<Tooltip label="Start live refresh">
32+
<Button
33+
fz={9}
34+
size="compact-md"
35+
aria-label="refresh-duration-selector"
36+
color={theme.colors?.gray?.[7]}
37+
className={classes.activateRefreshButton}
38+
>
39+
<IconRefresh />
40+
</Button>
41+
</Tooltip>
42+
</Popover.Target>
43+
<Popover.Dropdown>
44+
<Button.Group orientation="vertical">
45+
{liveRefreshOptions.map((p) => (
46+
<Button
47+
key={p.value}
48+
onClick={() => {
49+
startLiveRefresh(p.value);
50+
}}
51+
variant="default"
52+
aria-label={`refresh-duration-${p.value}`}
53+
>
54+
{p.label}
55+
</Button>
56+
))}
57+
</Button.Group>
58+
</Popover.Dropdown>
59+
</Popover>
60+
);
61+
};

src/Serilog.Ui.Web/src/app/components/ShellStructure/Header.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useSerilogUiProps } from 'app/hooks/useSerilogUiProps';
1111
import { isStringGuard } from 'app/util/guards';
1212
import { Suspense, lazy } from 'react';
1313
import classes from 'style/header.module.css';
14+
import { RefreshButton } from '../Refresh/RefreshButton';
1415
import BrandBadge from './BrandBadge';
1516

1617
const HeaderActivity = lazy(() => import('./HeaderActivity'));
@@ -69,6 +70,8 @@ const Head = ({ isMobileOpen, toggleMobile }: IProps) => {
6970
<IconMoonStars size="1rem" stroke="3" />
7071
)}
7172
</ActionIcon>
73+
74+
<RefreshButton />
7275
<BrandBadge size={isMobileSize ? 'sm' : 'lg'} />
7376
</Group>
7477
</Group>

0 commit comments

Comments
 (0)