@tpzdsp/next-toolkit 2.3.0 → 2.5.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 +3 -2
- package/src/components/Footer/Small/SmallFooter.stories.tsx +60 -0
- package/src/components/Footer/Small/SmallFooter.test.tsx +39 -0
- package/src/components/Footer/Small/SmallFooter.tsx +61 -0
- package/src/components/images/EaLogo.tsx +105 -43
- package/src/components/index.ts +1 -0
- package/src/http/fetch.test.ts +100 -0
- package/src/http/fetch.ts +40 -0
- package/src/http/query.test.tsx +366 -0
- package/src/http/query.ts +15 -17
- package/src/http/test.ts +0 -0
- package/src/map/MapControlsOverlay.tsx +25 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpzdsp/next-toolkit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "A reusable React component library for Next.js applications",
|
|
5
5
|
"packageManager": "pnpm@11.5.3",
|
|
6
6
|
"engines": {
|
|
@@ -122,11 +122,12 @@
|
|
|
122
122
|
"dev:push": "yalc push",
|
|
123
123
|
"tsc:check": "tsc -b --noEmit",
|
|
124
124
|
"storybook": "storybook dev -p 6006",
|
|
125
|
+
"build-storybook": "storybook build",
|
|
125
126
|
"release": "semantic-release",
|
|
126
127
|
"postinstall": "if [ \"$CI\" != \"true\" ]; then ./postinstall.sh; fi"
|
|
127
128
|
},
|
|
128
129
|
"devDependencies": {
|
|
129
|
-
"@better-fetch/fetch": "^1.1
|
|
130
|
+
"@better-fetch/fetch": "^1.3.1",
|
|
130
131
|
"@commitlint/cli": "^21.1.0",
|
|
131
132
|
"@commitlint/config-conventional": "^21.1.0",
|
|
132
133
|
"@commitlint/types": "^21.1.0",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
|
|
3
|
+
import { SmallFooter } from './SmallFooter';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/SmallFooter',
|
|
7
|
+
component: SmallFooter,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'fullscreen',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'A small site footer with EA logo, OGL licence links, and configurable EA logo colours.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
tags: ['autodocs'],
|
|
18
|
+
} satisfies Meta<typeof SmallFooter>;
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
parameters: {
|
|
26
|
+
docs: {
|
|
27
|
+
description: {
|
|
28
|
+
story: 'Default colours: grey background circle, green vine and text.',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const DarkBranding: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
eaLogoPrimaryFill: '#1d1d1b',
|
|
37
|
+
eaLogoHighlightFill: '#ffffff',
|
|
38
|
+
},
|
|
39
|
+
parameters: {
|
|
40
|
+
docs: {
|
|
41
|
+
description: {
|
|
42
|
+
story: 'Dark variant — white highlights on a near-black circle.',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const CustomBranding: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
eaLogoPrimaryFill: '#003078',
|
|
51
|
+
eaLogoHighlightFill: '#ffdd00',
|
|
52
|
+
},
|
|
53
|
+
parameters: {
|
|
54
|
+
docs: {
|
|
55
|
+
description: {
|
|
56
|
+
story: 'Example of fully custom primary and highlight colours.',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { SmallFooter } from './SmallFooter';
|
|
2
|
+
import { render, screen } from '../../../test/renderers';
|
|
3
|
+
|
|
4
|
+
describe('SmallFooter', () => {
|
|
5
|
+
it('renders as a footer landmark', () => {
|
|
6
|
+
render(<SmallFooter />);
|
|
7
|
+
|
|
8
|
+
expect(screen.getByRole('contentinfo')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('renders the Licencing details link with correct href', () => {
|
|
12
|
+
render(<SmallFooter />);
|
|
13
|
+
|
|
14
|
+
expect(screen.getByRole('link', { name: /licencing details/i })).toHaveAttribute(
|
|
15
|
+
'href',
|
|
16
|
+
'https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/',
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('renders the Crown copyright link with correct href', () => {
|
|
21
|
+
render(<SmallFooter />);
|
|
22
|
+
|
|
23
|
+
expect(screen.getByRole('link', { name: /crown copyright/i })).toHaveAttribute(
|
|
24
|
+
'href',
|
|
25
|
+
'https://www.nationalarchives.gov.uk/information-management/re-using-public-sector-information/uk-government-licensing-framework/crown-copyright/',
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('opens both links in a new tab with secure rel attribute', () => {
|
|
30
|
+
render(<SmallFooter />);
|
|
31
|
+
|
|
32
|
+
const links = screen.getAllByRole('link');
|
|
33
|
+
|
|
34
|
+
links.forEach((link) => {
|
|
35
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
36
|
+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { DefraLogo } from '../../images/DefraLogo';
|
|
2
|
+
import { EaLogo } from '../../images/EaLogo';
|
|
3
|
+
import { OglLogo } from '../../images/OglLogo';
|
|
4
|
+
|
|
5
|
+
const CROWN_COPYRIGHT_URL =
|
|
6
|
+
'https://www.nationalarchives.gov.uk/information-management/re-using-public-sector-information/uk-government-licensing-framework/crown-copyright/';
|
|
7
|
+
|
|
8
|
+
const LICENSING_DETAILS_URL =
|
|
9
|
+
'https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/';
|
|
10
|
+
|
|
11
|
+
export type SmallFooterProps = {
|
|
12
|
+
eaLogoPrimaryFill?: string;
|
|
13
|
+
eaLogoHighlightFill?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const SmallFooter = ({
|
|
17
|
+
eaLogoPrimaryFill = '#F3F2F1',
|
|
18
|
+
eaLogoHighlightFill = '#79A12F',
|
|
19
|
+
}: SmallFooterProps) => (
|
|
20
|
+
<footer
|
|
21
|
+
aria-label="Site footer"
|
|
22
|
+
className="flex justify-center border-t border-gray-200 bg-[##F3F2F1] px-4 py-2"
|
|
23
|
+
>
|
|
24
|
+
<div className="flex w-full max-w-[1020px] items-center justify-between px-4 md:px-36">
|
|
25
|
+
<div className="flex items-center gap-2">
|
|
26
|
+
<EaLogo
|
|
27
|
+
className="h-8 w-auto"
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
primaryFill={eaLogoPrimaryFill}
|
|
30
|
+
highlightFill={eaLogoHighlightFill}
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div className="flex items-center gap-2 text-sm">
|
|
35
|
+
<OglLogo className="h-4 w-auto" aria-hidden="true" />
|
|
36
|
+
|
|
37
|
+
<a
|
|
38
|
+
href={LICENSING_DETAILS_URL}
|
|
39
|
+
target="_blank"
|
|
40
|
+
rel="noopener noreferrer"
|
|
41
|
+
className="underline"
|
|
42
|
+
>
|
|
43
|
+
Licencing details
|
|
44
|
+
</a>
|
|
45
|
+
|
|
46
|
+
<span aria-hidden="true">|</span>
|
|
47
|
+
|
|
48
|
+
<a
|
|
49
|
+
href={CROWN_COPYRIGHT_URL}
|
|
50
|
+
target="_blank"
|
|
51
|
+
rel="noopener noreferrer"
|
|
52
|
+
className="underline"
|
|
53
|
+
>
|
|
54
|
+
© Crown copyright
|
|
55
|
+
</a>
|
|
56
|
+
|
|
57
|
+
<DefraLogo className="h-8 w-auto" aria-hidden="true" />
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</footer>
|
|
61
|
+
);
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
type EaLogoProps = React.SVGProps<SVGSVGElement
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
type EaLogoProps = Omit<React.SVGProps<SVGSVGElement>, 'fill' | 'color'> & {
|
|
2
|
+
primaryFill?: string;
|
|
3
|
+
highlightFill?: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const EaLogo = ({
|
|
7
|
+
className,
|
|
8
|
+
primaryFill = '#000000',
|
|
9
|
+
highlightFill = '#ffffff',
|
|
10
|
+
...props
|
|
11
|
+
}: EaLogoProps) => (
|
|
4
12
|
<svg
|
|
5
13
|
viewBox="0 0 641.32416 178.27345"
|
|
6
14
|
xmlns="http://www.w3.org/2000/svg"
|
|
@@ -14,7 +22,7 @@ export const EaLogo = ({ className, ...props }: EaLogoProps) => (
|
|
|
14
22
|
<ellipse
|
|
15
23
|
cx="70.870361"
|
|
16
24
|
cy="-70.511894"
|
|
17
|
-
fill=
|
|
25
|
+
fill={primaryFill}
|
|
18
26
|
rx="70.94368"
|
|
19
27
|
ry="71.30938"
|
|
20
28
|
transform="scale(1 -1)"
|
|
@@ -22,45 +30,99 @@ export const EaLogo = ({ className, ...props }: EaLogoProps) => (
|
|
|
22
30
|
|
|
23
31
|
<path
|
|
24
32
|
d="m47.4352 11.3129.6429 1.5902c5.3239 14.4938 11.5571 28.3508 15.2266 36.1895-.3067 5.1828-.9344 9.9054-1.5629 13.5949-14.2969 3.532-24.4395 13.2977-24.4395 13.2977l4.07 6.005s13.8617-11.1554 30.3691-11.1554c16.507 0 30.3596 11.1515 30.3596 11.1515l4.075-6.0035s-10.1584-9.7828-24.4748-13.3043c-.6133-3.6308-1.2364-8.2551-1.5477-13.3293 3.6-7.6703 9.7567-21.3633 15.0781-35.764l.593-1.634c22.9174 9.7289 38.9874 32.4383 38.9874 58.9008 0 35.325-28.636 63.961-63.9608 63.961-35.3242 0-63.96253-28.636-63.96253-63.961 0-27.0614 16.80433-50.195 40.54653-59.5391z"
|
|
25
|
-
fill=
|
|
33
|
+
fill={highlightFill}
|
|
26
34
|
></path>
|
|
27
35
|
|
|
28
|
-
<g fill=
|
|
29
|
-
<path
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<path
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<path
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
<path
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
36
|
+
<g fill={primaryFill}>
|
|
37
|
+
<path
|
|
38
|
+
fill={highlightFill}
|
|
39
|
+
d="m190.688 111.196 2 .127v9.94l-21.202-.124-14.082.125-.008-2.269c5.245-.175 6.459-.35 6.459-5.022v-26.7609c0-5.6426-.734-6.4535-6.459-6.4535v-2.282l15.45.1234 20.053-.1227c.712 3.2852 2.005 7.4915 2.977 10.1852l-2.149.3816c-2.579-6.2742-5.776-7.3113-12.284-7.6082h-3.566c-6.423 0-6.648 1.4024-6.648 8.0215v9.5742h6.92c5.277 0 7.055-.3566 7.055-5.7863h2.085l-.149 4.1246-.136 2.9221c0 2.085.171 6.056.309 7.423h-2.067c-.522-4.345-.895-5.501-5.398-5.501 0 0-7.962 0-8.619 0v16.091h8.864c6.924 0 9.648-1.831 10.595-7.109"
|
|
40
|
+
></path>
|
|
41
|
+
|
|
42
|
+
<path
|
|
43
|
+
fill={highlightFill}
|
|
44
|
+
d="m198.346 104.349c2.601-.595 3.828-1.908 3.828-4.118v-17.4994c0-1.7535-.811-1.8222-2.814-1.8222h-1.543v-2.4367l7.49.1394 7.361-.1394-.008 2.4367h-1.545c-2.043 0-2.945.0726-2.945 1.8222v19.3454c2.312 1.515 5.415 2.698 7.304 2.698 3.098 0 5.719-1.229 5.719-5.573v-16.4704c0-1.7535-.811-1.8222-2.811-1.8222h-1.548v-2.4367l7.49.1394 7.361-.1394-.008 2.4367h-1.543c-2.042 0-2.947.0726-2.947 1.8222v17.0368c0 6.5736-3.377 9.6016-7.947 9.6016-4.031 0-7.196-2.115-11.07-4.625v3.925c0 .823-.63.746-.873.643-2.421-1.052-6.33-2.433-8.951-3.209z"
|
|
45
|
+
></path>
|
|
46
|
+
|
|
47
|
+
<path
|
|
48
|
+
fill={highlightFill}
|
|
49
|
+
d="m459.53 104.349c2.602-.595 3.828-1.908 3.828-4.118v-17.4994c0-1.7535-.814-1.8222-2.814-1.8222h-1.543v-2.4367l7.49.1394 7.361-.1394-.007 2.4367h-1.546c-2.042 0-2.945.0726-2.945 1.8222v19.3454c2.309 1.515 5.416 2.698 7.304 2.698 3.099 0 5.72-1.229 5.72-5.573v-16.4704c0-1.7535-.814-1.8222-2.812-1.8222h-1.547v-2.4367l7.489.1394 7.361-.1394-.008 2.4367h-1.543c-2.045 0-2.947.0726-2.947 1.8222v17.0368c0 6.5736-3.378 9.6016-7.947 9.6016-4.03 0-7.195-2.115-11.07-4.625v3.925c0 .823-.633.746-.873.643-2.42-1.052-6.33-2.433-8.951-3.209z"
|
|
50
|
+
></path>
|
|
51
|
+
|
|
52
|
+
<path
|
|
53
|
+
fill={highlightFill}
|
|
54
|
+
d="m340.984 104.349c2.602-.595 3.828-1.908 3.828-4.118v-17.4994c0-1.7535-.813-1.8222-2.814-1.8222h-1.543v-2.4367l7.487.1394 7.364-.1394-.01 2.4367h-1.543c-2.042 0-2.947.0726-2.947 1.8222v19.3454c2.312 1.515 5.415 2.698 7.304 2.698 3.101 0 5.722-1.229 5.722-5.573v-16.4704c0-1.7535-.813-1.8222-2.814-1.8222h-1.548v-2.4367l7.492.1394 7.358-.1394-.005 2.4367h-1.543c-2.044 0-2.947.0726-2.947 1.8222v17.0368c0 6.5736-3.378 9.6016-7.947 9.6016-4.03 0-7.195-2.115-11.072-4.625v3.925c0 .823-.631.746-.87.643-2.424-1.052-6.331-2.433-8.952-3.209z"
|
|
55
|
+
></path>
|
|
56
|
+
|
|
57
|
+
<path
|
|
58
|
+
fill={highlightFill}
|
|
59
|
+
d="m262.828 105.95v2.436l-6.513-.139-5.48.141-.004-2.406c1.085.002 1.886-.058 2.462-.179.831-.175 1.202-.833 1.202-1.748 0-.914-.462-2.492-1.298-4.4874l-5.512-12.7426-5.647 14.167c-1.148 2.714-1.088 4.008.143 4.681.337.184 1.573.277 2.683.277v2.436l-7.749-.139-7.46.139-.005-2.436c2.512 0 3.491 0 5.479-4.854l9.73-23.6089h2.292l10.299 23.9319c1.209 2.819 2.233 4.531 5.378 4.531"
|
|
60
|
+
></path>
|
|
61
|
+
|
|
62
|
+
<path
|
|
63
|
+
fill={highlightFill}
|
|
64
|
+
d="m264.964 106.571v-1.533c1.874-.552 3.434-1.848 3.434-4.409v-17.8974c0-1.7535-.813-1.8222-2.811-1.8222h-1.612v-2.4367l7.555.1394 7.292-.1406v2.4379h-1.481c-2.045 0-2.947.0726-2.947 1.8222v25.3574c0 1.313-.71 1.411-1.66.986-1.582-.712-5.263-1.82-7.77-2.504"
|
|
65
|
+
></path>
|
|
66
|
+
|
|
67
|
+
<path
|
|
68
|
+
fill={highlightFill}
|
|
69
|
+
d="m267.805 118.686c0-1.914 1.712-3.657 3.591-3.657 1.95 0 3.596 1.675 3.596 3.657 0 1.946-1.612 3.529-3.596 3.529-1.879 0-3.591-1.681-3.591-3.529"
|
|
70
|
+
></path>
|
|
71
|
+
|
|
72
|
+
<path
|
|
73
|
+
fill={highlightFill}
|
|
74
|
+
d="m282.635 104.024c2.27-.595 3.434-1.478 3.434-4.004v-17.2884c0-1.7535-.813-1.8222-2.813-1.8222h-2.142v-2.4356l8.219.1383 7.302-.0961 2.298-.0433v2.4367h-4.053c-2.003 0-2.816.0687-2.816 1.8222v15.5184c0 3.786 3.557 5.123 4.467 5.123 1.073 0 1.58-.368 2.116-.759.557-.405 1.133-.823 2.241-.823 1.941 0 3.462 1.636 3.462 3.723 0 2.66-2.409 3.856-4.12 3.856-2.809 0-6.211-2.451-8.229-5.524v4.626c0 1.021-.736 1.113-1.582.642-1.424-.791-5.499-2.768-7.77-3.634z"
|
|
75
|
+
></path>
|
|
76
|
+
|
|
77
|
+
<path
|
|
78
|
+
fill={highlightFill}
|
|
79
|
+
d="m312.512 93.9574c0 6.0606 2.648 12.6676 9.072 12.6676 6.513 0 9.235-7.2187 9.235-13.7234 0-6.0629-2.668-12.6672-9.141-12.6672-6.468 0-9.166 7.216-9.166 13.723zm-6.79-.6578c0-10.0066 6.555-15.8125 16.02-15.8125 8.57 0 15.862 6.3684 15.862 16.0746 0 10.1533-6.426 15.8083-16.084 15.8083-8.679 0-15.798-6.215-15.798-16.0704"
|
|
80
|
+
></path>
|
|
81
|
+
|
|
82
|
+
<path
|
|
83
|
+
fill={highlightFill}
|
|
84
|
+
d="m405.051 104.464c-1.041 3.14-3.531 4.906-7.154 4.906-4.586 0-7.554-2.115-10.991-4.563 0 0-.059-.044-.076-.055v3.337c0 1.157-.92 1.631-2.186.902-.304-.173-4.058-1.467-7.245-2.42v-1.533c2.01-.592 3.434-1.848 3.434-4.409v-17.8974c0-1.7535-.811-1.8222-2.813-1.8222h-1.744v-2.4395l7.688.1422 7.363-.1394-.007 2.4367h-1.546c-2.042 0-2.944.0726-2.944 1.8222v19.3484c1.822 1.247 5.185 3.012 7.435 3.012 3.778 0 5.39-2.323 5.39-7.767v-14.5934c0-1.7535-.81-1.8222-2.813-1.8222h-1.546v-2.4367l7.49.1394 7.422-.1394v2.4367h-1.545c-2.035 0-3.012.0769-3.012 1.8222v19.3484c1.823 1.247 5.186 3.012 7.436 3.012 3.778 0 5.388-2.323 5.388-7.767v-14.5934c0-1.7535-.814-1.8222-2.814-1.8222h-1.543v-2.4367l7.486.1394 7.167-.1406-.008 2.4379h-1.347c-2.043 0-2.945.0726-2.945 1.8222v16.97c0 6.0544-3.072 9.6684-8.214 9.6684-4.145 0-8.472-2.815-11.206-4.906"
|
|
85
|
+
></path>
|
|
86
|
+
|
|
87
|
+
<path
|
|
88
|
+
fill={highlightFill}
|
|
89
|
+
d="m437.188 99.5332c.868 4.3738 3.481 7.0098 7.121 7.0098 3.271 0 5.385-2.245 5.385-5.719 0-.77-.467-1.2908-1.491-1.2908zm17.993-2.8289.868 1.0957c0 8.076-5.804 11.57-11.478 11.57-5.507 0-13.696-4.561-13.696-17.1286 0-5.1035 2.71-14.7543 12.969-14.7543 6.001 0 9.834 4.0649 12.502 7.6047l-1.462 1.3934c-2.989-2.9891-5.662-4.3223-8.661-4.3223-4.933 0-8.953 4.7672-9.355 11.0914-.064 1.1187-.098 1.9223-.098 2.5133 0 .4398.034.6875.069.9367z"
|
|
90
|
+
></path>
|
|
91
|
+
|
|
92
|
+
<path
|
|
93
|
+
fill={highlightFill}
|
|
94
|
+
d="m262.059 47.3523c2.601-.5937 3.827-1.9043 3.827-4.1152v-17.5012c0-1.7507-.813-1.825-2.814-1.825h-1.542v-2.4328l7.489.1391 7.363-.1391-.008 2.4328h-1.545c-2.043 0-2.945.0743-2.945 1.825v19.3461c2.309 1.5137 5.415 2.6981 7.304 2.6981 3.098 0 5.719-1.2293 5.719-5.5762v-16.468c0-1.7507-.813-1.825-2.811-1.825h-1.548v-2.4328l7.49.1391 7.36-.1391-.006 2.4328h-1.543c-2.045 0-2.948.0743-2.948 1.825v17.0364c0 6.5722-3.378 9.6035-7.947 9.6035-4.03 0-7.195-2.116-11.07-4.6258v3.9234c0 .8246-.633.7473-.873.6438-2.422-1.0508-6.331-2.4336-8.952-3.2102z"
|
|
95
|
+
></path>
|
|
96
|
+
|
|
97
|
+
<path
|
|
98
|
+
fill={highlightFill}
|
|
99
|
+
d="m240.032 42.5379c.868 4.3738 3.482 7.0098 7.122 7.0098 3.271 0 5.385-2.245 5.385-5.7168 0-.7715-.467-1.293-1.491-1.293zm17.994-2.827.868 1.093c0 8.0758-5.804 11.5719-11.478 11.5719-5.507 0-13.696-4.5645-13.696-17.1309 0-5.1027 2.71-14.7508 12.969-14.7508 6.001 0 9.833 4.0598 12.501 7.6l-1.461 1.395c-2.989-2.9871-5.662-4.3219-8.662-4.3219-4.933 0-8.953 4.7668-9.354 11.0918-.064 1.1172-.098 1.9242-.098 2.5129 0 .439.034.6859.069.939z"
|
|
100
|
+
></path>
|
|
101
|
+
|
|
102
|
+
<path
|
|
103
|
+
fill={highlightFill}
|
|
104
|
+
d="m492.963 106.323.003-1.59h4.356v-20.7478c0-4.1899 2.354-6.4981 6.629-6.4981 3.076 0 6.033 1.5051 9.035 4.5992l-1.478 1.5082c-1.511-1.3152-2.74-1.8269-4.387-1.8269-.892 0-3.806.3734-3.806 5.1898v17.7756h9.526v3.645h-9.526v6.654h-1.448c-3.01-4.781-6.303-7.156-8.904-8.709"
|
|
105
|
+
></path>
|
|
106
|
+
|
|
107
|
+
<path
|
|
108
|
+
fill={highlightFill}
|
|
109
|
+
d="m176.08 56.2977 6.844-16.2106h-13.053zm2.129 8.7609c-.895-1.2727-3.472-2.5481-4.342-2.8645.633-2.1757.235-3.5554-1.049-6.7468-.022-.0539-9.445-22.7832-9.445-22.7832-.653-1.6469-1.323-3.3532-2.136-4.9352-1.652-3.1859-2.641-3.8328-6.365-4.0301v-2.2207l8.325.1231 8.437-.1231-.005 2.25-2.425-.0402c-2.431.0992-3.806.332-3.806 2.4633 0 1.8008 1.017 4.6289 1.832 6.9039l1.417 3.8969h15.6l3.336-8.3379c.487-1.1332 1.065-2.725 1.001-3.293-.133-1.1582-1.147-1.593-3.41-1.593h-1.587v-2.25l10.353.1231 7.917-.1231-.005 2.25c-1.698 0-3.748.0641-5.039 1.4778-.86.9402-2.156 4.3082-3.128 6.741l-13.889 33.1117z"
|
|
110
|
+
></path>
|
|
111
|
+
|
|
112
|
+
<path
|
|
113
|
+
fill={highlightFill}
|
|
114
|
+
d="m210.049 42.7773c0 3.452 2.025 7.1688 5.771 7.1688 4.231 0 6.13-4.1719 6.13-8.2961 0-3.5609-1.588-7.3691-6.048-7.3691-4.021 0-5.853 3.5601-5.853 8.4964zm-2.309-27.1453c0 5.8512 6.382 5.8512 10.605 5.8512 5.944 0 8.956-1.6773 8.956-4.9902 0-5.9399-8.088-7.23987-11.204-7.23987-3.855 0-8.357 1.67067-8.357 6.37887zm-2.777 14.6489c-.529-.6879-.841-1.8797-.841-2.6309 0-2.8941 2.557-4.782 5.208-5.752-3.734-.8511-7.126-3.3929-7.126-7.7199 0-4.58396 4.416-7.74919 12.84-7.74919 13.951 0 17.855 8.78829 17.855 12.71519 0 4.7969-3.111 7.3438-9.403 7.3438-1.655 0-6.981-.0047-9.115.0633-4.26.1488-4.263 2.0238-4.263 2.6859 0 1.4039.859 2.2059 1.724 2.9918 1.236-.2367 2.514-.3758 3.531-.3758 8.253 0 12.571 5.2969 12.571 10.5239 0 1.6863-.574 3.3035-1.464 4.6382 1.019-.0386 6.285-.2515 6.285-.2515v3.9511c-1.783-.2726-3.08-.3828-4.186-.3828-2.463 0-4.413.5606-6.11 1.0504-2.008.5785-4.411.9934-6.105.9934-7.719 0-12.707-4.1309-12.707-10.5281 0-3.8426 1.882-6.5676 5.949-8.8086z"
|
|
115
|
+
></path>
|
|
116
|
+
|
|
117
|
+
<path
|
|
118
|
+
fill={highlightFill}
|
|
119
|
+
d="m322.173 29.1582c-2.647-2.057-4.795-3.1652-7.989-3.1613-7.833.0152-10.798 5.0941-10.821 12.6461-.017 5.1136 3.722 10.5531 8.403 10.5093 3.456-.0304 4.453-1.4589 5.605-3.0019.776-1.0332 1.508-2.0078 2.764-2.018 1.835-.0144 2.769 1.5918 2.747 3.1848-.037 2.9426-3.776 4.8469-9.373 4.9004-9.169.0898-16.077-7.2742-16.077-17.2547 0-8.2277 5.959-14.2809 13.401-14.2809 5.581 0 10.024 3.3282 12.937 6.834z"
|
|
120
|
+
></path>
|
|
121
|
+
|
|
122
|
+
<path
|
|
123
|
+
fill={highlightFill}
|
|
124
|
+
d="m338.23 22.8969c-.217-.5778-1.583-4.1239-4.397-10.118-.806.618-1.518.8313-2.551.8313-2.018 0-3.526-1.9293-3.526-3.65903 0-2.0082 1.543-3.52226 3.59-3.52226 2.082 0 3.528 1.11797 4.554 3.51718l14.925 35.00191c1.193 2.7649 2.429 4.0606 5.247 4.0008v2.4485l-5.308-.1438-6.521.1391-.044-2.4356c.949 0 1.706-.0453 2.284-.2101.96-.2774 1.414-.8953 1.414-2.2063 0-.3855-.261-1.2508-.676-2.398l-5.539-13.5305-5.45 13.6984c-.549 1.375-1.041 2.6708-.961 3.2196.098.6828.513 1.048 1.307 1.2433.573.1414 1.345.1954 2.334.211l-.007 2.4082-6.302-.1391-8.017.1391v-2.4727c.45-.0195 1.093.0196 1.562-.0937 1.182-.2871 2.132-.9153 2.646-2.1321z"
|
|
125
|
+
></path>
|
|
64
126
|
|
|
65
127
|
<path d="m79.4563 80.6016c.1925.5906.3011 1.2222.3011 1.8879 0 3.2242-2.4824 5.8339-5.5484 5.8339-3.0563 0-5.5387-2.6097-5.5387-5.8339 0-3.2254 2.4824-5.8368 5.5387-5.8368l.5344.0274c-.4254-.1508-.8758-.2746-1.3407-.3613-4.8855-.9075-9.5492 2.5343-10.4144 7.6792-.8559 5.1489 2.4082 10.0547 7.2988 10.9633 4.8863.9063 9.55-2.5269 10.4102-7.6789.4054-2.4082-.0942-4.7683-1.241-6.6808z"></path>
|
|
66
128
|
|
|
@@ -69,12 +131,12 @@ export const EaLogo = ({ className, ...props }: EaLogoProps) => (
|
|
|
69
131
|
|
|
70
132
|
<path
|
|
71
133
|
d="m87.9793 9.21094-.6402 1.61016c-4.5055 11.0519-15.5485 31.6848-15.5485 31.6848s-11.3441-21.1911-15.7207-32.1207l-.6656-1.61723c4.9477-1.22578 10.1207-1.87578 15.4469-1.87578 5.932 0 11.6758.80898 17.1281 2.31875z"
|
|
72
|
-
fill=
|
|
134
|
+
fill={highlightFill}
|
|
73
135
|
></path>
|
|
74
136
|
|
|
75
137
|
<path
|
|
76
138
|
d="m57.1328 107.771c-1.7109 2.694-4.6976 4.505-8.1297 4.563-5.4304.098-9.9054-4.229-9.9996-9.653-.0097-.542.025-1.068.0942-1.585 3.0113-.773 8.0113-4.08 8.2734-10.7003-2.725 3.6012-5.168 4.7285-9.3023 5.091-5.8997.5196-10.9536-4.6547-11.0477-10.4219-.0445-2.2453.6277-4.3343 1.8004-6.0535-2.7254-2.4254-4.4707-5.9418-4.5402-9.8797-.1286-7.4628 3.7242-12.7839 10.1625-13.9367 3.5363-.6226 9.3664.2035 12.7539 4.0024-3.0657-6.4297-8.5649-8.9375-14.1184-9.0024-9.1637-.1031-16.3645 8.0676-16.2059 17.2258.0891 4.9395 2.3243 9.3383 5.8059 12.3156-1.1723 2.1594-1.8199 4.6496-1.7754 7.2789.1285 7.3305 5.5828 13.3138 12.6055 14.3378-.0297.468-.0446.941-.0395 1.419.1531 8.484 7.1457 15.243 15.627 15.092 2.7445-.049 5.3066-.811 7.5066-2.107 2.6262 2.687 6.3059 4.323 10.3555 4.251 6.9387-.119 12.6207-5.219 13.7035-11.83-2.6508 3.372-6.7352 5.565-11.3543 5.645-4.9848.085-9.4406-2.31-12.1754-6.052z"
|
|
77
|
-
fill=
|
|
139
|
+
fill={primaryFill}
|
|
78
140
|
></path>
|
|
79
141
|
</g>
|
|
80
142
|
</svg>
|
package/src/components/index.ts
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import z from 'zod/v4';
|
|
2
|
+
|
|
3
|
+
import { apiFetch } from './fetch';
|
|
4
|
+
import { ApiError, ApiErrorSchema } from '../errors';
|
|
5
|
+
|
|
6
|
+
const UserSchema = z.object({ id: z.number(), name: z.string() });
|
|
7
|
+
|
|
8
|
+
const USERS_URL = 'https://api.test/users/1';
|
|
9
|
+
const CREATE_USER_URL = 'https://api.test/create-user';
|
|
10
|
+
const INVALID_NAME_ERROR = { message: 'Invalid name', details: 'name is required' };
|
|
11
|
+
|
|
12
|
+
const stubFetchResponse = (status: number, body: unknown) => {
|
|
13
|
+
vi.stubGlobal(
|
|
14
|
+
'fetch',
|
|
15
|
+
vi.fn(
|
|
16
|
+
async () =>
|
|
17
|
+
new Response(JSON.stringify(body), {
|
|
18
|
+
status,
|
|
19
|
+
headers: { 'Content-Type': 'application/json' },
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('apiFetch', () => {
|
|
26
|
+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
// `ApiError` logs to `console.error` on construction - not relevant to what these tests assert on.
|
|
30
|
+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
consoleErrorSpy.mockRestore();
|
|
35
|
+
vi.unstubAllGlobals();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns a `{ data, error }` tuple on success by default', async () => {
|
|
39
|
+
stubFetchResponse(200, { id: 1, name: 'Alice' });
|
|
40
|
+
|
|
41
|
+
const result = await apiFetch<{ id: number; name: string }>(USERS_URL, {
|
|
42
|
+
errorSchema: ApiErrorSchema,
|
|
43
|
+
output: UserSchema,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(result).toEqual({ data: { id: 1, name: 'Alice' }, error: null });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('resolves directly with the data when `throw: true`', async () => {
|
|
50
|
+
stubFetchResponse(200, { id: 1, name: 'Alice' });
|
|
51
|
+
|
|
52
|
+
const data = await apiFetch<{ id: number; name: string }>(USERS_URL, {
|
|
53
|
+
errorSchema: ApiErrorSchema,
|
|
54
|
+
output: UserSchema,
|
|
55
|
+
throw: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(data).toEqual({ id: 1, name: 'Alice' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns the raw, unparsed error in the tuple when the request fails and `throw` is not set', async () => {
|
|
62
|
+
stubFetchResponse(404, { message: 'Not Found' });
|
|
63
|
+
|
|
64
|
+
const result = await apiFetch('https://api.test/users/999', { errorSchema: ApiErrorSchema });
|
|
65
|
+
|
|
66
|
+
expect(result.data).toBeNull();
|
|
67
|
+
expect(result.error).toMatchObject({ message: 'Not Found', status: 404 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('throws a normalized `ApiError` when the request fails and `throw: true`', async () => {
|
|
71
|
+
stubFetchResponse(400, INVALID_NAME_ERROR);
|
|
72
|
+
|
|
73
|
+
await expect(
|
|
74
|
+
apiFetch(CREATE_USER_URL, { errorSchema: ApiErrorSchema, throw: true }),
|
|
75
|
+
).rejects.toThrow(ApiError);
|
|
76
|
+
|
|
77
|
+
stubFetchResponse(400, INVALID_NAME_ERROR);
|
|
78
|
+
|
|
79
|
+
const error = await apiFetch<never>(CREATE_USER_URL, {
|
|
80
|
+
errorSchema: ApiErrorSchema,
|
|
81
|
+
throw: true,
|
|
82
|
+
}).catch((caught: unknown) => caught as ApiError);
|
|
83
|
+
|
|
84
|
+
expect(error).toBeInstanceOf(ApiError);
|
|
85
|
+
expect(error.message).toBe(INVALID_NAME_ERROR.message);
|
|
86
|
+
expect(error.details).toBe(INVALID_NAME_ERROR.details);
|
|
87
|
+
expect(error.status).toBe(400);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('normalizes a network-level failure into an `ApiError`, regardless of `throw`', async () => {
|
|
91
|
+
vi.stubGlobal(
|
|
92
|
+
'fetch',
|
|
93
|
+
vi.fn(async () => {
|
|
94
|
+
throw new TypeError('fetch failed');
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await expect(apiFetch(USERS_URL, { errorSchema: ApiErrorSchema })).rejects.toThrow(ApiError);
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/http/fetch.ts
CHANGED
|
@@ -4,12 +4,14 @@ import z from 'zod/v4';
|
|
|
4
4
|
import {
|
|
5
5
|
createSchema as betterFetchCreateSchema,
|
|
6
6
|
createFetch as betterFetchCreateFetch,
|
|
7
|
+
betterFetch,
|
|
7
8
|
type CreateFetchOption as BetterFetchCreateFetchOption,
|
|
8
9
|
type Schema,
|
|
9
10
|
type FetchSchema,
|
|
10
11
|
ValidationError,
|
|
11
12
|
BetterFetchError,
|
|
12
13
|
type BetterFetch,
|
|
14
|
+
type BetterFetchResponse,
|
|
13
15
|
type StandardSchemaV1,
|
|
14
16
|
type BetterFetchPlugin,
|
|
15
17
|
} from '@better-fetch/fetch';
|
|
@@ -263,3 +265,41 @@ export const createFetch = <
|
|
|
263
265
|
}
|
|
264
266
|
}) as typeof instance;
|
|
265
267
|
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Performs a single, one-off `better-fetch` request without needing a fetch schema or a
|
|
271
|
+
* `createFetch` instance - useful for requests that don't belong to any particular API/schema.
|
|
272
|
+
*
|
|
273
|
+
* Applies the same defaults as {@link createFetch} (basic auth from environment variables, the
|
|
274
|
+
* toolkit plugin) and normalizes any thrown error into an {@link ApiError}.
|
|
275
|
+
*
|
|
276
|
+
* As with plain `better-fetch`, pass `throw: true` in `options` to have a failed request throw
|
|
277
|
+
* (normalized to an {@link ApiError}) instead of resolving to a `{ data, error }` tuple - the
|
|
278
|
+
* `error` side of that tuple is whatever the server responded with, unparsed, same as plain
|
|
279
|
+
* `better-fetch` (only the *thrown* error path is normalized/validated against `errorSchema`).
|
|
280
|
+
*/
|
|
281
|
+
type ApiFetch = {
|
|
282
|
+
// `throw: true` - resolves directly with the data, or throws a normalized `ApiError`.
|
|
283
|
+
<TRes = unknown, Option extends CreateFetchOption = CreateFetchOption>(
|
|
284
|
+
url: string,
|
|
285
|
+
options: Option & { throw: true },
|
|
286
|
+
): Promise<TRes>;
|
|
287
|
+
|
|
288
|
+
// Default (`throw` unset/`false`) - resolves with a `{ data, error }` tuple.
|
|
289
|
+
<TRes = unknown, Option extends CreateFetchOption = CreateFetchOption>(
|
|
290
|
+
url: string,
|
|
291
|
+
options: Option,
|
|
292
|
+
): Promise<BetterFetchResponse<TRes, unknown, false>>;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export const apiFetch: ApiFetch = async (url: string, options: CreateFetchOption) => {
|
|
296
|
+
try {
|
|
297
|
+
return await betterFetch(url, {
|
|
298
|
+
auth: getAuth(),
|
|
299
|
+
...options,
|
|
300
|
+
plugins: [...(options.plugins ?? []), TOOLKIT_PLUGIN],
|
|
301
|
+
});
|
|
302
|
+
} catch (error) {
|
|
303
|
+
throw await normalizeError(error, options);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Suspense } from 'react';
|
|
3
|
+
|
|
4
|
+
import { ErrorBoundary } from 'react-error-boundary';
|
|
5
|
+
import z from 'zod/v4';
|
|
6
|
+
|
|
7
|
+
import { type BetterFetch, type CreateFetchOption } from '@better-fetch/fetch';
|
|
8
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
9
|
+
|
|
10
|
+
import { HttpMethod } from './constants';
|
|
11
|
+
import { createSchema } from './fetch';
|
|
12
|
+
import { reactQueryBetterFetch } from './query';
|
|
13
|
+
import { ApiError } from '../errors';
|
|
14
|
+
import { render, renderHook, screen, userEvent, waitFor } from '../test/renderers';
|
|
15
|
+
|
|
16
|
+
const Route = {
|
|
17
|
+
USERS: '/users',
|
|
18
|
+
USER_BY_ID: '/users/:id',
|
|
19
|
+
RAW_USERS: '/raw-users',
|
|
20
|
+
CREATE_USER: '/create-user',
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
const UserSchema = z.object({ id: z.number(), name: z.string() });
|
|
24
|
+
const CreateUserInputSchema = z.object({ name: z.string() });
|
|
25
|
+
|
|
26
|
+
// A schema with a mix of routes to exercise every branch of the hook overloads:
|
|
27
|
+
// - a route with a schema-defined `output` (`RoutesWithOutput`)
|
|
28
|
+
// - a route with a schema-defined `output` AND a `params` schema (`RoutesWithInput`)
|
|
29
|
+
// - a route with no `output` at all (`RoutesWithoutOutput`, requires a manual generic)
|
|
30
|
+
// - a route with a schema-defined `input` (`RoutesWithInput`, used for mutations)
|
|
31
|
+
const testSchema = createSchema({
|
|
32
|
+
[Route.USERS]: {
|
|
33
|
+
method: HttpMethod.Get,
|
|
34
|
+
output: z.array(UserSchema),
|
|
35
|
+
},
|
|
36
|
+
[Route.USER_BY_ID]: {
|
|
37
|
+
method: HttpMethod.Get,
|
|
38
|
+
output: UserSchema,
|
|
39
|
+
params: z.object({ id: z.string() }),
|
|
40
|
+
},
|
|
41
|
+
[Route.RAW_USERS]: {
|
|
42
|
+
method: HttpMethod.Get,
|
|
43
|
+
},
|
|
44
|
+
[Route.CREATE_USER]: {
|
|
45
|
+
method: HttpMethod.Post,
|
|
46
|
+
input: CreateUserInputSchema,
|
|
47
|
+
output: UserSchema,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
type TestOption = CreateFetchOption & { schema: typeof testSchema };
|
|
52
|
+
|
|
53
|
+
// `reactQueryBetterFetch` only cares about `instance` as a callable matching `BetterFetch<Option>` -
|
|
54
|
+
// the actual fetching is entirely mocked here, so we don't need a real network layer to test the
|
|
55
|
+
// hooks' schema-driven typing, query/mutation key building, and React Query wiring.
|
|
56
|
+
const createTestInstance = () => {
|
|
57
|
+
const fetchMock = vi.fn();
|
|
58
|
+
const instance = fetchMock as unknown as BetterFetch<TestOption>;
|
|
59
|
+
|
|
60
|
+
return { fetchMock, instance };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const createTestQueryClient = () =>
|
|
64
|
+
new QueryClient({
|
|
65
|
+
defaultOptions: {
|
|
66
|
+
queries: { retry: false },
|
|
67
|
+
mutations: { retry: false },
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const createQueryWrapper = (client: QueryClient) => {
|
|
72
|
+
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
73
|
+
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return Wrapper;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Renders the fetched user's name - shared between the suspense tests below, which only differ in
|
|
80
|
+
// how the fetch mock resolves/rejects and what's wrapped around this component.
|
|
81
|
+
const createUserNameComponent = (
|
|
82
|
+
useBetterSuspenseQuery: ReturnType<
|
|
83
|
+
typeof reactQueryBetterFetch<TestOption>
|
|
84
|
+
>['useBetterSuspenseQuery'],
|
|
85
|
+
) => {
|
|
86
|
+
const UserName = () => {
|
|
87
|
+
const { data } = useBetterSuspenseQuery(Route.USER_BY_ID, { params: { id: '1' } });
|
|
88
|
+
|
|
89
|
+
return <p>{data.name}</p>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return UserName;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
describe('reactQueryBetterFetch', () => {
|
|
96
|
+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
// `ApiError` logs to `console.error` on construction, and React logs errors caught by an
|
|
100
|
+
// `ErrorBoundary` - neither is relevant to what these tests assert on.
|
|
101
|
+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
consoleErrorSpy.mockRestore();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('builds a strict, schema-validated route map for the test fixture', () => {
|
|
109
|
+
expect(Object.keys(testSchema.schema)).toEqual(Object.values(Route));
|
|
110
|
+
expect(testSchema.config).toEqual({ strict: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('useBetterQuerySafe', () => {
|
|
114
|
+
it('fetches data for a route with a schema-defined output', async () => {
|
|
115
|
+
const { fetchMock, instance } = createTestInstance();
|
|
116
|
+
|
|
117
|
+
fetchMock.mockResolvedValue([{ id: 1, name: 'Alice' }]);
|
|
118
|
+
|
|
119
|
+
const { useBetterQuerySafe } = reactQueryBetterFetch<TestOption>(instance);
|
|
120
|
+
const queryClient = createTestQueryClient();
|
|
121
|
+
|
|
122
|
+
const { result } = renderHook(() => useBetterQuerySafe(Route.USERS), {
|
|
123
|
+
wrapper: createQueryWrapper(queryClient),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
127
|
+
|
|
128
|
+
expect(result.current.data).toEqual([{ id: 1, name: 'Alice' }]);
|
|
129
|
+
expect(fetchMock).toHaveBeenCalledWith(Route.USERS, undefined);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('supports a manually specified output type for routes without a schema output', async () => {
|
|
133
|
+
const { fetchMock, instance } = createTestInstance();
|
|
134
|
+
|
|
135
|
+
fetchMock.mockResolvedValue([{ id: 9, extra: true }]);
|
|
136
|
+
|
|
137
|
+
const { useBetterQuerySafe } = reactQueryBetterFetch<TestOption>(instance);
|
|
138
|
+
const queryClient = createTestQueryClient();
|
|
139
|
+
|
|
140
|
+
const { result } = renderHook(
|
|
141
|
+
() => useBetterQuerySafe<{ id: number; extra: boolean }[]>(Route.RAW_USERS),
|
|
142
|
+
{ wrapper: createQueryWrapper(queryClient) },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
146
|
+
|
|
147
|
+
expect(result.current.data).toEqual([{ id: 9, extra: true }]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('builds the query key from the route and fetch options, refetching when they change', async () => {
|
|
151
|
+
const { fetchMock, instance } = createTestInstance();
|
|
152
|
+
|
|
153
|
+
fetchMock.mockImplementation(async (_route: string, options: { params: { id: string } }) => ({
|
|
154
|
+
id: Number(options.params.id),
|
|
155
|
+
name: `User ${options.params.id}`,
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
const { useBetterQuerySafe } = reactQueryBetterFetch<TestOption>(instance);
|
|
159
|
+
const queryClient = createTestQueryClient();
|
|
160
|
+
|
|
161
|
+
const { result, rerender } = renderHook(
|
|
162
|
+
({ id }: { id: string }) => useBetterQuerySafe(Route.USER_BY_ID, { params: { id } }),
|
|
163
|
+
{ wrapper: createQueryWrapper(queryClient), initialProps: { id: '1' } },
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await waitFor(() => expect(result.current.data).toEqual({ id: 1, name: 'User 1' }));
|
|
167
|
+
|
|
168
|
+
rerender({ id: '2' });
|
|
169
|
+
|
|
170
|
+
await waitFor(() => expect(result.current.data).toEqual({ id: 2, name: 'User 2' }));
|
|
171
|
+
|
|
172
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('applies a `select` transform, exposing the transformed shape as `data`', async () => {
|
|
176
|
+
const { fetchMock, instance } = createTestInstance();
|
|
177
|
+
|
|
178
|
+
fetchMock.mockResolvedValue({ id: 1, name: 'Alice' });
|
|
179
|
+
|
|
180
|
+
const { useBetterQuerySafe } = reactQueryBetterFetch<TestOption>(instance);
|
|
181
|
+
const queryClient = createTestQueryClient();
|
|
182
|
+
|
|
183
|
+
const { result } = renderHook(
|
|
184
|
+
() =>
|
|
185
|
+
useBetterQuerySafe(
|
|
186
|
+
Route.USER_BY_ID,
|
|
187
|
+
{ params: { id: '1' } },
|
|
188
|
+
{ select: (user) => user.name },
|
|
189
|
+
),
|
|
190
|
+
{ wrapper: createQueryWrapper(queryClient) },
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
194
|
+
|
|
195
|
+
expect(result.current.data).toBe('Alice');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns the error instead of throwing when the request fails', async () => {
|
|
199
|
+
const apiError = ApiError.notFound('user not found');
|
|
200
|
+
const { fetchMock, instance } = createTestInstance();
|
|
201
|
+
|
|
202
|
+
fetchMock.mockRejectedValue(apiError);
|
|
203
|
+
|
|
204
|
+
const { useBetterQuerySafe } = reactQueryBetterFetch<TestOption>(instance);
|
|
205
|
+
const queryClient = createTestQueryClient();
|
|
206
|
+
|
|
207
|
+
const { result } = renderHook(
|
|
208
|
+
() => useBetterQuerySafe(Route.USER_BY_ID, { params: { id: '1' } }),
|
|
209
|
+
{ wrapper: createQueryWrapper(queryClient) },
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
213
|
+
|
|
214
|
+
expect(result.current.error).toBe(apiError);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('useBetterSuspenseQuery', () => {
|
|
219
|
+
it('suspends while loading and resolves with the schema-derived data', async () => {
|
|
220
|
+
const { fetchMock, instance } = createTestInstance();
|
|
221
|
+
|
|
222
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
223
|
+
let resolveFetch: (value: unknown) => void = () => {};
|
|
224
|
+
|
|
225
|
+
fetchMock.mockReturnValue(
|
|
226
|
+
new Promise((resolve) => {
|
|
227
|
+
resolveFetch = resolve;
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const { useBetterSuspenseQuery } = reactQueryBetterFetch<TestOption>(instance);
|
|
232
|
+
const queryClient = createTestQueryClient();
|
|
233
|
+
const UserName = createUserNameComponent(useBetterSuspenseQuery);
|
|
234
|
+
|
|
235
|
+
render(
|
|
236
|
+
<QueryClientProvider client={queryClient}>
|
|
237
|
+
<Suspense fallback={<p>loading...</p>}>
|
|
238
|
+
<UserName />
|
|
239
|
+
</Suspense>
|
|
240
|
+
</QueryClientProvider>,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(screen.getByText('loading...')).toBeInTheDocument();
|
|
244
|
+
|
|
245
|
+
resolveFetch({ id: 1, name: 'Alice' });
|
|
246
|
+
|
|
247
|
+
expect(await screen.findByText('Alice')).toBeInTheDocument();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('throws to the nearest error boundary when the request fails', async () => {
|
|
251
|
+
const apiError = ApiError.internalServerError('boom');
|
|
252
|
+
const { fetchMock, instance } = createTestInstance();
|
|
253
|
+
|
|
254
|
+
fetchMock.mockRejectedValue(apiError);
|
|
255
|
+
|
|
256
|
+
const { useBetterSuspenseQuery } = reactQueryBetterFetch<TestOption>(instance);
|
|
257
|
+
const queryClient = createTestQueryClient();
|
|
258
|
+
const onError = vi.fn();
|
|
259
|
+
const UserName = createUserNameComponent(useBetterSuspenseQuery);
|
|
260
|
+
|
|
261
|
+
render(
|
|
262
|
+
<QueryClientProvider client={queryClient}>
|
|
263
|
+
<ErrorBoundary fallback={<p>failed</p>} onError={onError}>
|
|
264
|
+
<Suspense fallback={<p>loading...</p>}>
|
|
265
|
+
<UserName />
|
|
266
|
+
</Suspense>
|
|
267
|
+
</ErrorBoundary>
|
|
268
|
+
</QueryClientProvider>,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(await screen.findByText('failed')).toBeInTheDocument();
|
|
272
|
+
expect(onError).toHaveBeenCalledWith(apiError, expect.anything());
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('supports a manually specified output type for routes without a schema output', async () => {
|
|
276
|
+
const { fetchMock, instance } = createTestInstance();
|
|
277
|
+
|
|
278
|
+
fetchMock.mockResolvedValue({ id: 9, extra: true });
|
|
279
|
+
|
|
280
|
+
const { useBetterSuspenseQuery } = reactQueryBetterFetch<TestOption>(instance);
|
|
281
|
+
const queryClient = createTestQueryClient();
|
|
282
|
+
|
|
283
|
+
const { result } = renderHook(
|
|
284
|
+
() => useBetterSuspenseQuery<{ id: number; extra: boolean }>(Route.RAW_USERS),
|
|
285
|
+
{ wrapper: createQueryWrapper(queryClient) },
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
289
|
+
|
|
290
|
+
expect(result.current.data).toEqual({ id: 9, extra: true });
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe('useBetterMutation', () => {
|
|
295
|
+
it('sends the merged fetch and mutation input and returns the schema output', async () => {
|
|
296
|
+
const { fetchMock, instance } = createTestInstance();
|
|
297
|
+
|
|
298
|
+
fetchMock.mockResolvedValue({ id: 5, name: 'Bob' });
|
|
299
|
+
|
|
300
|
+
const { useBetterMutation } = reactQueryBetterFetch<TestOption>(instance);
|
|
301
|
+
const queryClient = createTestQueryClient();
|
|
302
|
+
|
|
303
|
+
const { result } = renderHook(() => useBetterMutation(Route.CREATE_USER), {
|
|
304
|
+
wrapper: createQueryWrapper(queryClient),
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const data = await result.current.mutateAsync({ body: { name: 'Bob' } });
|
|
308
|
+
|
|
309
|
+
expect(data).toEqual({ id: 5, name: 'Bob' });
|
|
310
|
+
expect(fetchMock).toHaveBeenCalledWith(Route.CREATE_USER, { body: { name: 'Bob' } });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('throws the error during render so the nearest error boundary catches it', async () => {
|
|
314
|
+
const user = userEvent.setup();
|
|
315
|
+
const apiError = ApiError.badRequest('invalid name');
|
|
316
|
+
const { fetchMock, instance } = createTestInstance();
|
|
317
|
+
|
|
318
|
+
fetchMock.mockRejectedValue(apiError);
|
|
319
|
+
|
|
320
|
+
const { useBetterMutation } = reactQueryBetterFetch<TestOption>(instance);
|
|
321
|
+
const queryClient = createTestQueryClient();
|
|
322
|
+
const onError = vi.fn();
|
|
323
|
+
|
|
324
|
+
const CreateUserButton = () => {
|
|
325
|
+
const { mutate } = useBetterMutation(Route.CREATE_USER);
|
|
326
|
+
|
|
327
|
+
return <button onClick={() => mutate({ body: { name: '' } })}>create</button>;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
render(
|
|
331
|
+
<QueryClientProvider client={queryClient}>
|
|
332
|
+
<ErrorBoundary fallback={<p>failed</p>} onError={onError}>
|
|
333
|
+
<CreateUserButton />
|
|
334
|
+
</ErrorBoundary>
|
|
335
|
+
</QueryClientProvider>,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
await user.click(screen.getByRole('button', { name: 'create' }));
|
|
339
|
+
|
|
340
|
+
expect(await screen.findByText('failed')).toBeInTheDocument();
|
|
341
|
+
expect(onError).toHaveBeenCalledWith(apiError, expect.anything());
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('useBetterMutationSafe', () => {
|
|
346
|
+
it('does not throw on error, exposing it via the mutation result instead', async () => {
|
|
347
|
+
const apiError = ApiError.badRequest('invalid name');
|
|
348
|
+
const { fetchMock, instance } = createTestInstance();
|
|
349
|
+
|
|
350
|
+
fetchMock.mockRejectedValue(apiError);
|
|
351
|
+
|
|
352
|
+
const { useBetterMutationSafe } = reactQueryBetterFetch<TestOption>(instance);
|
|
353
|
+
const queryClient = createTestQueryClient();
|
|
354
|
+
|
|
355
|
+
const { result } = renderHook(() => useBetterMutationSafe(Route.CREATE_USER), {
|
|
356
|
+
wrapper: createQueryWrapper(queryClient),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
result.current.mutate({ body: { name: '' } });
|
|
360
|
+
|
|
361
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
362
|
+
|
|
363
|
+
expect(result.current.error).toBe(apiError);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
});
|
package/src/http/query.ts
CHANGED
|
@@ -130,30 +130,32 @@ type ReactQueryBetterFetch = <
|
|
|
130
130
|
useBetterQuerySafe<
|
|
131
131
|
Error = ApiError,
|
|
132
132
|
Route extends RoutesWithOutput<Routes> = RoutesWithOutput<Routes>,
|
|
133
|
+
Output = SchemaRouteOutput<Routes, Route>,
|
|
134
|
+
// The type actually returned as `data`. Defaults to `Output` (i.e. `select` is the identity
|
|
135
|
+
// function), but can be inferred as something else when a transforming `select` is passed.
|
|
136
|
+
Data = Output,
|
|
133
137
|
>(
|
|
134
138
|
route: Route,
|
|
135
139
|
// If the route requires an input, then expect options that provide that input, otherwise just expect options without inputs.
|
|
136
140
|
fetchOptions?: Route extends RoutesWithInput<Routes>
|
|
137
141
|
? OptionsWithInput<Routes, Route>
|
|
138
142
|
: OptionsNoInputOutput,
|
|
139
|
-
queryOptions?: Omit<
|
|
140
|
-
|
|
141
|
-
OmitReactQueryKeys
|
|
142
|
-
>,
|
|
143
|
-
): UseQueryResult<SchemaRouteOutput<Routes, Route>, Error>;
|
|
143
|
+
queryOptions?: Omit<UseQueryOptions<Output, Error, Data, QueryKey>, OmitReactQueryKeys>,
|
|
144
|
+
): UseQueryResult<Data, Error>;
|
|
144
145
|
|
|
145
146
|
// Overload that allows manual output type instead of schema (`RoutesWithoutOutput`)
|
|
146
147
|
useBetterQuerySafe<
|
|
147
148
|
Output,
|
|
148
149
|
Error = ApiError,
|
|
149
150
|
Route extends RoutesWithoutOutput<Routes> = RoutesWithoutOutput<Routes>,
|
|
151
|
+
Data = Output,
|
|
150
152
|
>(
|
|
151
153
|
route: Route,
|
|
152
154
|
fetchOptions?: Route extends RoutesWithInput<Routes>
|
|
153
155
|
? OptionsWithInput<Routes, Route>
|
|
154
156
|
: OptionsNoInputOutput,
|
|
155
|
-
queryOptions?: Omit<UseQueryOptions<
|
|
156
|
-
): UseQueryResult<
|
|
157
|
+
queryOptions?: Omit<UseQueryOptions<Output, Error, Data, QueryKey>, OmitReactQueryKeys>,
|
|
158
|
+
): UseQueryResult<Data, Error>;
|
|
157
159
|
|
|
158
160
|
// Suspense query (error thrown)
|
|
159
161
|
|
|
@@ -161,31 +163,27 @@ type ReactQueryBetterFetch = <
|
|
|
161
163
|
useBetterSuspenseQuery<
|
|
162
164
|
Route extends RoutesWithOutput<Routes>,
|
|
163
165
|
Output = SchemaRouteOutput<Routes, Route>,
|
|
166
|
+
Data = Output,
|
|
164
167
|
>(
|
|
165
168
|
route: Route,
|
|
166
169
|
fetchOptions?: Route extends RoutesWithInput<Routes>
|
|
167
170
|
? OptionsWithInput<Routes, Route>
|
|
168
171
|
: OptionsNoInputOutput,
|
|
169
|
-
queryOptions?: Omit<
|
|
170
|
-
|
|
171
|
-
OmitReactQueryKeys
|
|
172
|
-
>,
|
|
173
|
-
): UseSuspenseQueryResult<Output>;
|
|
172
|
+
queryOptions?: Omit<UseSuspenseQueryOptions<Output, never, Data, QueryKey>, OmitReactQueryKeys>,
|
|
173
|
+
): UseSuspenseQueryResult<Data>;
|
|
174
174
|
|
|
175
175
|
// Overload that allows manual output type instead of schema (`RoutesWithoutOutput`)
|
|
176
176
|
useBetterSuspenseQuery<
|
|
177
177
|
Output,
|
|
178
178
|
Route extends RoutesWithoutOutput<Routes> = RoutesWithoutOutput<Routes>,
|
|
179
|
+
Data = Output,
|
|
179
180
|
>(
|
|
180
181
|
route: Route,
|
|
181
182
|
fetchOptions?: Route extends RoutesWithInput<Routes>
|
|
182
183
|
? OptionsWithInput<Routes, Route>
|
|
183
184
|
: OptionsNoInputOutput,
|
|
184
|
-
queryOptions?: Omit<
|
|
185
|
-
|
|
186
|
-
OmitReactQueryKeys
|
|
187
|
-
>,
|
|
188
|
-
): UseSuspenseQueryResult<Output>;
|
|
185
|
+
queryOptions?: Omit<UseSuspenseQueryOptions<Output, never, Data, QueryKey>, OmitReactQueryKeys>,
|
|
186
|
+
): UseSuspenseQueryResult<Data>;
|
|
189
187
|
|
|
190
188
|
// Mutation query (error thrown)
|
|
191
189
|
|
package/src/http/test.ts
ADDED
|
File without changes
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
type RefObject,
|
|
11
|
+
} from 'react';
|
|
4
12
|
|
|
5
13
|
import BaseLayer from 'ol/layer/Base';
|
|
6
14
|
import { LuLayers, LuList, LuMaximize2, LuMinus, LuMinimize2, LuPlus } from 'react-icons/lu';
|
|
@@ -14,6 +22,11 @@ type MapControlsOverlayProps = {
|
|
|
14
22
|
isLegendVisible?: boolean;
|
|
15
23
|
isLegendEnabled?: boolean;
|
|
16
24
|
legendContent?: ReactNode;
|
|
25
|
+
// Whether the map is the currently visible view (e.g. the "Map" tab is
|
|
26
|
+
// active). The legend renders via a portal to document.body, so it isn't
|
|
27
|
+
// hidden by the normal tab-panel `hidden` mechanism when another tab is
|
|
28
|
+
// shown — this closes it explicitly instead.
|
|
29
|
+
isActive?: boolean;
|
|
17
30
|
};
|
|
18
31
|
|
|
19
32
|
type ControlButtonProps = {
|
|
@@ -60,6 +73,7 @@ export const MapControlsOverlay = ({
|
|
|
60
73
|
isLegendVisible = false,
|
|
61
74
|
isLegendEnabled = false,
|
|
62
75
|
legendContent,
|
|
76
|
+
isActive = true,
|
|
63
77
|
}: MapControlsOverlayProps) => {
|
|
64
78
|
const { map, getLayers } = useMap();
|
|
65
79
|
const [isFullScreen, setIsFullScreen] = useState(false);
|
|
@@ -120,6 +134,16 @@ export const MapControlsOverlay = ({
|
|
|
120
134
|
legendButtonRef.current?.focus();
|
|
121
135
|
}, []);
|
|
122
136
|
|
|
137
|
+
// Force-close on leaving the map view rather than remembering the open
|
|
138
|
+
// state — the legend describes what's on the map, so re-opening it after
|
|
139
|
+
// returning is a deliberate, cheap action rather than something worth
|
|
140
|
+
// restoring automatically.
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!isActive) {
|
|
143
|
+
setIsLegendOpen(false);
|
|
144
|
+
}
|
|
145
|
+
}, [isActive]);
|
|
146
|
+
|
|
123
147
|
const handleSelectLayer = useCallback(
|
|
124
148
|
(layerName: string) => {
|
|
125
149
|
const current = basemapLayers.find((l: BaseLayer) => l.getVisible());
|