@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.7.0",
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
- 'A UI component displayed when an error is caught by `ErrorBoundary`. It shows the error message, optional cause, and a retry button.',
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 fn
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: 'Default rendering when given a standard JavaScript `Error`.',
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, 'Extra details'),
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: 'Rendering when the error is an `ApiError` that includes extra details.',
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(/there was an error/i);
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('Something went wrong');
36
+ const error = new Error('custom error message');
19
37
 
20
38
  render(<ErrorFallback error={error} resetErrorBoundary={identityFn} />);
21
39
 
22
- expect(screen.getByText(/reason: something went wrong/i)).toBeInTheDocument();
40
+ expect(screen.getByRole('alert')).toHaveTextContent(/reason: custom error message/i);
23
41
  });
24
42
 
25
- it('renders message and cause for ApiError instance', () => {
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
- expect(screen.getByText(/reason: fake reason/i)).toBeInTheDocument();
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 cause only when it is a string', () => {
34
- const error = new Error('Has a cause', { cause: ["I won't display"] });
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.getByText(/reason: has a cause/i)).toBeInTheDocument();
39
- expect(screen.queryByText(/cause:/i)).not.toBeInTheDocument();
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 Try Again is clicked', async () => {
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: /try again/i });
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 { type FallbackProps } from 'react-error-boundary';
1
+ import { useId } from 'react';
2
2
 
3
- import { Button, ErrorText } from '@tpzdsp/next-toolkit/components';
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
- <ErrorText>
25
- There was an error. <br />
26
- Reason: {message}
27
- </ErrorText>
28
- {details ? (
29
- <span>
30
- <br />
31
-
32
- <details>
33
- <summary>Details</summary>
34
-
35
- <pre>{details}</pre>
36
- </details>
37
- </span>
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
- <p
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
- </p>
17
+ </Component>
16
18
  );
17
19
  };
@@ -1,11 +1,14 @@
1
1
  import { twMerge } from 'tailwind-merge';
2
2
 
3
- export type HeadingProps = {
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={`pb-4 text-sm text-text-primary ${className}`} {...props}>
9
+ <p className={twMerge('pb-4 text-sm text-text-primary', className)} {...props}>
8
10
  {children}
9
11
  </p>
10
12
  );
@@ -75,7 +75,7 @@ export const MapComponent = ({
75
75
  }
76
76
 
77
77
  // Initialise map's basemap layers.
78
- const layers = initializeBasemapLayers();
78
+ const layers = initializeBasemapLayers(basePath);
79
79
 
80
80
  const target = mapRef.current;
81
81
 
@@ -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: '/water-quality-archive/api/maps/raster/v1/zxy/Light_3857/{z}/{x}/{y}.png',
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: '/water-quality-archive/api/maps/raster/v1/zxy/Outdoor_3857/{z}/{x}/{y}.png',
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: '/water-quality-archive/api/maps/raster/v1/zxy/Road_3857/{z}/{x}/{y}.png',
53
+ url: `${baseUrl}/api/maps/raster/v1/zxy/Road_3857/{z}/{x}/{y}.png`,
54
54
  }),
55
55
  visible: false,
56
56
  });
@@ -0,0 +1,6 @@
1
+ import { format, type DateArg } from 'date-fns';
2
+
3
+ export const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd';
4
+
5
+ export const formatDate = (date: DateArg<Date>, pattern = DEFAULT_DATE_FORMAT) =>
6
+ format(date instanceof Date ? date : new Date(date), pattern);
@@ -1,3 +1,4 @@
1
1
  export * from './utils';
2
2
  export * from './auth';
3
3
  export * from './constants';
4
+ export * from './date';