@tpzdsp/next-toolkit 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -1
- package/src/components/ErrorBoundary/ErrorFallback.stories.tsx +8 -6
- package/src/components/ErrorBoundary/ErrorFallback.test.tsx +66 -13
- package/src/components/ErrorBoundary/ErrorFallback.tsx +61 -23
- package/src/components/ErrorText/ErrorText.tsx +4 -2
- package/src/components/Heading/Heading.tsx +6 -3
- package/src/components/Paragraph/Paragraph.tsx +3 -1
- package/src/map/MapComponent.tsx +1 -1
- package/src/map/basemaps.ts +4 -4
- package/src/utils/date.ts +6 -0
- package/src/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpzdsp/next-toolkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "A reusable React component library for Next.js applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -150,6 +150,7 @@
|
|
|
150
150
|
"autoprefixer": "^10.4.21",
|
|
151
151
|
"buffer": "^6.0.3",
|
|
152
152
|
"crypto-browserify": "^3.12.1",
|
|
153
|
+
"date-fns": "^4.1.0",
|
|
153
154
|
"eslint": "^9.30.1",
|
|
154
155
|
"eslint-config-prettier": "^10.1.8",
|
|
155
156
|
"eslint-import-resolver-alias": "^1.1.2",
|
|
@@ -206,6 +207,7 @@
|
|
|
206
207
|
"@turf/turf": "^7.2.0",
|
|
207
208
|
"@types/geojson": "^7946.0.16",
|
|
208
209
|
"@types/jsonwebtoken": "^9.0.10",
|
|
210
|
+
"date-fns": "^4.1.0",
|
|
209
211
|
"geojson": "^0.5.0",
|
|
210
212
|
"jsonwebtoken": "^9.0.2",
|
|
211
213
|
"next": "^15.4.2",
|
|
@@ -220,6 +222,12 @@
|
|
|
220
222
|
"zod": "^4.1.8"
|
|
221
223
|
},
|
|
222
224
|
"peerDependenciesMeta": {
|
|
225
|
+
"@tanstack/react-query": {
|
|
226
|
+
"optional": true
|
|
227
|
+
},
|
|
228
|
+
"@better-fetch/fetch": {
|
|
229
|
+
"optional": true
|
|
230
|
+
},
|
|
223
231
|
"@turf/turf": {
|
|
224
232
|
"optional": true
|
|
225
233
|
},
|
|
@@ -229,6 +237,9 @@
|
|
|
229
237
|
"@types/jsonwebtoken": {
|
|
230
238
|
"optional": true
|
|
231
239
|
},
|
|
240
|
+
"date-fns": {
|
|
241
|
+
"optional": true
|
|
242
|
+
},
|
|
232
243
|
"geojson": {
|
|
233
244
|
"optional": true
|
|
234
245
|
},
|
|
@@ -249,6 +260,9 @@
|
|
|
249
260
|
},
|
|
250
261
|
"react-select": {
|
|
251
262
|
"optional": true
|
|
263
|
+
},
|
|
264
|
+
"zod": {
|
|
265
|
+
"optional": true
|
|
252
266
|
}
|
|
253
267
|
},
|
|
254
268
|
"optionalDependencies": {
|
|
@@ -13,7 +13,7 @@ const meta = {
|
|
|
13
13
|
docs: {
|
|
14
14
|
description: {
|
|
15
15
|
component:
|
|
16
|
-
'
|
|
16
|
+
'Displayed when an error is caught by `ErrorBoundary`. Shows the error message, optional details in an Accordion, and Copy/Retry buttons.',
|
|
17
17
|
},
|
|
18
18
|
},
|
|
19
19
|
},
|
|
@@ -23,7 +23,7 @@ const meta = {
|
|
|
23
23
|
export default meta;
|
|
24
24
|
type Story = StoryObj<typeof meta>;
|
|
25
25
|
|
|
26
|
-
// Shared mock reset
|
|
26
|
+
// Shared mock reset function
|
|
27
27
|
const mockReset = () => alert('Reset error boundary called');
|
|
28
28
|
|
|
29
29
|
export const Default: Story = {
|
|
@@ -34,7 +34,8 @@ export const Default: Story = {
|
|
|
34
34
|
parameters: {
|
|
35
35
|
docs: {
|
|
36
36
|
description: {
|
|
37
|
-
story:
|
|
37
|
+
story:
|
|
38
|
+
'Default rendering with a standard JavaScript `Error`. Shows heading, reason, and Copy/Retry buttons.',
|
|
38
39
|
},
|
|
39
40
|
},
|
|
40
41
|
},
|
|
@@ -42,13 +43,14 @@ export const Default: Story = {
|
|
|
42
43
|
|
|
43
44
|
export const WithApiError: Story = {
|
|
44
45
|
args: {
|
|
45
|
-
error: new ApiError('Request failed', HttpStatus.InternalServerError, '
|
|
46
|
+
error: new ApiError('Request failed', HttpStatus.InternalServerError, 'Some technical details'),
|
|
46
47
|
resetErrorBoundary: mockReset,
|
|
47
48
|
},
|
|
48
49
|
parameters: {
|
|
49
50
|
docs: {
|
|
50
51
|
description: {
|
|
51
|
-
story:
|
|
52
|
+
story:
|
|
53
|
+
'Rendering when the error is an `ApiError`. Shows reason, details in an Accordion, digest, and Copy/Retry buttons.',
|
|
52
54
|
},
|
|
53
55
|
},
|
|
54
56
|
},
|
|
@@ -63,7 +65,7 @@ export const WithUnknownError: Story = {
|
|
|
63
65
|
docs: {
|
|
64
66
|
description: {
|
|
65
67
|
story:
|
|
66
|
-
'If the error is not an `Error` or `ApiError`, a generic "Unknown" message is shown.',
|
|
68
|
+
'If the error is not an `Error` or `ApiError`, a generic "Unknown error" message is shown, with Copy/Retry buttons.',
|
|
67
69
|
},
|
|
68
70
|
},
|
|
69
71
|
},
|
|
@@ -1,54 +1,107 @@
|
|
|
1
1
|
import { ErrorFallback } from './ErrorFallback';
|
|
2
2
|
import { ApiError } from '../../errors';
|
|
3
3
|
import { HttpStatus } from '../../http';
|
|
4
|
-
import { render, screen, userEvent } from '../../test/renderers';
|
|
4
|
+
import { render, screen, userEvent, within } from '../../test/renderers';
|
|
5
5
|
import { identityFn } from '../../utils/utils';
|
|
6
6
|
|
|
7
|
+
const FAKE_UUID = 'fake-uuid';
|
|
8
|
+
|
|
9
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
10
|
+
value: {
|
|
11
|
+
writeText: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
},
|
|
13
|
+
configurable: true,
|
|
14
|
+
});
|
|
15
|
+
|
|
7
16
|
describe('ErrorFallback', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
(navigator.clipboard.writeText as unknown) = vi.fn().mockResolvedValue(undefined);
|
|
19
|
+
vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
8
26
|
it('renders a generic error message for an unknown error type', () => {
|
|
9
27
|
render(<ErrorFallback error={{} as unknown as Error} resetErrorBoundary={identityFn} />);
|
|
10
28
|
|
|
11
29
|
const alert = screen.getByRole('alert');
|
|
12
30
|
|
|
13
|
-
expect(alert).toHaveTextContent(/
|
|
31
|
+
expect(alert).toHaveTextContent(/something went wrong/i);
|
|
14
32
|
expect(alert).toHaveTextContent(/reason: unknown/i);
|
|
15
33
|
});
|
|
16
34
|
|
|
17
35
|
it('renders message for standard Error instance', () => {
|
|
18
|
-
const error = new Error('
|
|
36
|
+
const error = new Error('custom error message');
|
|
19
37
|
|
|
20
38
|
render(<ErrorFallback error={error} resetErrorBoundary={identityFn} />);
|
|
21
39
|
|
|
22
|
-
expect(screen.
|
|
40
|
+
expect(screen.getByRole('alert')).toHaveTextContent(/reason: custom error message/i);
|
|
23
41
|
});
|
|
24
42
|
|
|
25
|
-
it('renders message and
|
|
26
|
-
const apiError = new ApiError('Fake Reason', HttpStatus.InternalServerError);
|
|
43
|
+
it('renders message, details and digest for ApiError instance', async () => {
|
|
44
|
+
const apiError = new ApiError('Fake Reason', HttpStatus.InternalServerError, 'Some details');
|
|
27
45
|
|
|
28
46
|
render(<ErrorFallback error={apiError} resetErrorBoundary={identityFn} />);
|
|
29
47
|
|
|
30
|
-
|
|
48
|
+
// Message shown
|
|
49
|
+
expect(screen.getByRole('alert')).toHaveTextContent(/reason: fake reason/i);
|
|
50
|
+
|
|
51
|
+
// Accordion exists but details hidden until toggled
|
|
52
|
+
const accordionButton = screen.getByRole('button', { name: /show details/i });
|
|
53
|
+
|
|
54
|
+
expect(accordionButton).toBeInTheDocument();
|
|
55
|
+
|
|
56
|
+
// Expand and check details text
|
|
57
|
+
await userEvent.click(accordionButton);
|
|
58
|
+
|
|
59
|
+
const section = screen.getByRole('region', { hidden: false });
|
|
60
|
+
|
|
61
|
+
expect(within(section).getByText(/error id:/i)).toBeInTheDocument();
|
|
62
|
+
expect(within(section).getByText(/some details/i)).toBeInTheDocument();
|
|
31
63
|
});
|
|
32
64
|
|
|
33
|
-
it('renders
|
|
34
|
-
const error = new Error('Has a cause', { cause: [
|
|
65
|
+
it('renders non-string causes', () => {
|
|
66
|
+
const error = new Error('Has a cause', { cause: ['I will display'] });
|
|
35
67
|
|
|
36
68
|
render(<ErrorFallback error={error} resetErrorBoundary={identityFn} />);
|
|
37
69
|
|
|
38
|
-
expect(screen.
|
|
39
|
-
expect(screen.
|
|
70
|
+
expect(screen.getByRole('alert')).toHaveTextContent(/reason: has a cause/i);
|
|
71
|
+
expect(screen.queryByRole('button', { name: /show details/i })).toBeInTheDocument();
|
|
40
72
|
});
|
|
41
73
|
|
|
42
|
-
it('calls resetErrorBoundary when
|
|
74
|
+
it('calls resetErrorBoundary when Retry is clicked', async () => {
|
|
43
75
|
const user = userEvent.setup();
|
|
44
76
|
const resetSpy = vi.fn();
|
|
45
77
|
|
|
46
78
|
render(<ErrorFallback error={new Error('Test error')} resetErrorBoundary={resetSpy} />);
|
|
47
79
|
|
|
48
|
-
const button = screen.getByRole('button', { name: /
|
|
80
|
+
const button = screen.getByRole('button', { name: /retry/i });
|
|
49
81
|
|
|
50
82
|
await user.click(button);
|
|
51
83
|
|
|
52
84
|
expect(resetSpy).toHaveBeenCalledTimes(1);
|
|
53
85
|
});
|
|
86
|
+
|
|
87
|
+
it('copies error info to clipboard when Copy is clicked', async () => {
|
|
88
|
+
const user = userEvent.setup();
|
|
89
|
+
const apiError = new ApiError('Copy Reason', HttpStatus.BadRequest, 'Some details');
|
|
90
|
+
|
|
91
|
+
render(<ErrorFallback error={apiError} resetErrorBoundary={identityFn} />);
|
|
92
|
+
|
|
93
|
+
const copyButton = screen.getByRole('button', { name: /copy/i });
|
|
94
|
+
|
|
95
|
+
await user.click(copyButton);
|
|
96
|
+
|
|
97
|
+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
|
98
|
+
expect.stringContaining('App Error: Copy Reason'),
|
|
99
|
+
);
|
|
100
|
+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
|
101
|
+
expect.stringContaining('Details: Some details'),
|
|
102
|
+
);
|
|
103
|
+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
|
104
|
+
expect.stringContaining(`Digest: ${FAKE_UUID}`),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
54
107
|
});
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useId } from 'react';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { type FallbackProps } from 'react-error-boundary';
|
|
4
4
|
|
|
5
5
|
import { ApiError } from '../../errors/ApiError';
|
|
6
|
+
import { Accordion } from '../accordion/Accordion';
|
|
7
|
+
import { Button } from '../Button/Button';
|
|
8
|
+
import { Heading } from '../Heading/Heading';
|
|
9
|
+
import { Paragraph } from '../Paragraph/Paragraph';
|
|
6
10
|
|
|
7
11
|
export const ErrorFallback = ({ resetErrorBoundary, error }: FallbackProps) => {
|
|
12
|
+
const id = useId();
|
|
13
|
+
|
|
8
14
|
let message;
|
|
9
15
|
let details = undefined;
|
|
10
16
|
let digest = undefined;
|
|
@@ -12,35 +18,67 @@ export const ErrorFallback = ({ resetErrorBoundary, error }: FallbackProps) => {
|
|
|
12
18
|
if (error instanceof ApiError) {
|
|
13
19
|
message = error.message;
|
|
14
20
|
details = error.details;
|
|
21
|
+
digest = error.digest;
|
|
15
22
|
} else if (error instanceof Error) {
|
|
16
23
|
message = error.message;
|
|
17
24
|
details = error.cause?.toString();
|
|
18
25
|
} else {
|
|
19
|
-
message = 'Unknown';
|
|
26
|
+
message = 'Unknown error';
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
const copyToClipboard = () => {
|
|
30
|
+
navigator.clipboard.writeText(
|
|
31
|
+
// eslint-disable-next-line sonarjs/no-nested-template-literals
|
|
32
|
+
`App Error: ${message}, ${details ? `Details: ${details}, ` : ''}${digest ? `Digest: ${digest}` : ''}`,
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
22
36
|
return (
|
|
23
|
-
<div
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
<div
|
|
38
|
+
role="alert"
|
|
39
|
+
aria-labelledby={id}
|
|
40
|
+
className="grid gap-2 border-form border-transparent border-l-error max-w-full pl-2"
|
|
41
|
+
style={{ gridTemplateColumns: '1fr auto' }}
|
|
42
|
+
>
|
|
43
|
+
<div className="flex flex-col gap-0.5">
|
|
44
|
+
<Heading id={id} type="h3" className="text-error text-base font-semibold py-0">
|
|
45
|
+
Something went wrong
|
|
46
|
+
</Heading>
|
|
47
|
+
|
|
48
|
+
<Paragraph className="leading-snug pb-0">
|
|
49
|
+
<strong>Reason:</strong> {message}
|
|
50
|
+
</Paragraph>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div className="flex items-start gap-1">
|
|
54
|
+
<Button onClick={copyToClipboard} variant="secondary" className="shrink-0">
|
|
55
|
+
Copy
|
|
56
|
+
</Button>
|
|
57
|
+
|
|
58
|
+
<Button onClick={resetErrorBoundary} variant="secondary" className="shrink-0">
|
|
59
|
+
Retry
|
|
60
|
+
</Button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{details && (
|
|
64
|
+
<div className="col-span-2">
|
|
65
|
+
<Accordion title="Show details">
|
|
66
|
+
<div className="flex gap-0.5 flex-col">
|
|
67
|
+
{digest && (
|
|
68
|
+
<Paragraph className="leading-snug pb-0">
|
|
69
|
+
<strong>Error ID:</strong> {digest}
|
|
70
|
+
</Paragraph>
|
|
71
|
+
)}
|
|
72
|
+
<pre
|
|
73
|
+
className="text-base bg-white border border-gray-200 p-2 overflow-y-auto max-h-32
|
|
74
|
+
whitespace-pre-wrap"
|
|
75
|
+
>
|
|
76
|
+
{details}
|
|
77
|
+
</pre>
|
|
78
|
+
</div>
|
|
79
|
+
</Accordion>
|
|
80
|
+
</div>
|
|
40
81
|
)}
|
|
41
|
-
<br />
|
|
42
|
-
Digest: {digest ?? 'No digest provided'}
|
|
43
|
-
<Button onClick={resetErrorBoundary}>Try Again</Button>
|
|
44
82
|
</div>
|
|
45
83
|
);
|
|
46
84
|
};
|
|
@@ -5,13 +5,15 @@ import type { ExtendProps } from '../../types/utils';
|
|
|
5
5
|
export type ErrorTextProps = ExtendProps<'p'>;
|
|
6
6
|
|
|
7
7
|
export const ErrorText = ({ className, children, ...props }: ErrorTextProps) => {
|
|
8
|
+
const Component = typeof children === 'string' ? 'p' : 'div';
|
|
9
|
+
|
|
8
10
|
return (
|
|
9
|
-
<
|
|
11
|
+
<Component
|
|
10
12
|
role="alert"
|
|
11
13
|
className={twMerge('mb-3 text-base text-error font-bold', className)}
|
|
12
14
|
{...props}
|
|
13
15
|
>
|
|
14
16
|
{children}
|
|
15
|
-
</
|
|
17
|
+
</Component>
|
|
16
18
|
);
|
|
17
19
|
};
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { twMerge } from 'tailwind-merge';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import type { ExtendProps } from '../../types';
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
4
6
|
type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
5
|
-
className?: string;
|
|
6
|
-
children: React.ReactNode;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
+
// h1 through h6 have identical props
|
|
10
|
+
export type HeadingProps = ExtendProps<'h1', Props>;
|
|
11
|
+
|
|
9
12
|
export const Heading = ({ type, className, children }: HeadingProps) => {
|
|
10
13
|
switch (type) {
|
|
11
14
|
case 'h1':
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { twMerge } from 'tailwind-merge';
|
|
2
|
+
|
|
1
3
|
import type { ExtendProps } from '../../types/utils';
|
|
2
4
|
|
|
3
5
|
export type ParagraphProps = ExtendProps<'p'>;
|
|
4
6
|
|
|
5
7
|
export const Paragraph = ({ className, children, ...props }: ParagraphProps) => {
|
|
6
8
|
return (
|
|
7
|
-
<p className={
|
|
9
|
+
<p className={twMerge('pb-4 text-sm text-text-primary', className)} {...props}>
|
|
8
10
|
{children}
|
|
9
11
|
</p>
|
|
10
12
|
);
|
package/src/map/MapComponent.tsx
CHANGED
package/src/map/basemaps.ts
CHANGED
|
@@ -8,7 +8,7 @@ import SatelliteMapTilerImage from './images/basemaps/satellite-map-tiler.png';
|
|
|
8
8
|
import SatelliteImage from './images/basemaps/satellite.png';
|
|
9
9
|
import StreetsImage from './images/basemaps/streets.png';
|
|
10
10
|
|
|
11
|
-
export const initializeBasemapLayers = () => {
|
|
11
|
+
export const initializeBasemapLayers = (baseUrl: string) => {
|
|
12
12
|
const osmLayer = new TileLayer({
|
|
13
13
|
preload: Infinity,
|
|
14
14
|
source: new OSM(),
|
|
@@ -24,7 +24,7 @@ export const initializeBasemapLayers = () => {
|
|
|
24
24
|
const osMapsLight = new TileLayer({
|
|
25
25
|
// attributions: [`© Crown Copyright and Database Rights [insert year of creation] OS AC0000807064`],
|
|
26
26
|
source: new XYZ({
|
|
27
|
-
url:
|
|
27
|
+
url: `${baseUrl}/api/maps/raster/v1/zxy/Light_3857/{z}/{x}/{y}.png`,
|
|
28
28
|
}),
|
|
29
29
|
visible: false,
|
|
30
30
|
});
|
|
@@ -36,7 +36,7 @@ export const initializeBasemapLayers = () => {
|
|
|
36
36
|
const osMapsOutdoor = new TileLayer({
|
|
37
37
|
// attributions: [`© Crown Copyright and Database Rights [insert year of creation] OS AC0000807064`],
|
|
38
38
|
source: new XYZ({
|
|
39
|
-
url:
|
|
39
|
+
url: `${baseUrl}/api/maps/raster/v1/zxy/Outdoor_3857/{z}/{x}/{y}.png`,
|
|
40
40
|
}),
|
|
41
41
|
visible: false,
|
|
42
42
|
});
|
|
@@ -50,7 +50,7 @@ export const initializeBasemapLayers = () => {
|
|
|
50
50
|
const osMapsRoad = new TileLayer({
|
|
51
51
|
// attributions: [`© Crown Copyright and Database Rights [insert year of creation] OS AC0000807064`],
|
|
52
52
|
source: new XYZ({
|
|
53
|
-
url:
|
|
53
|
+
url: `${baseUrl}/api/maps/raster/v1/zxy/Road_3857/{z}/{x}/{y}.png`,
|
|
54
54
|
}),
|
|
55
55
|
visible: false,
|
|
56
56
|
});
|
package/src/utils/index.ts
CHANGED