Skip to content

Commit 5165cd2

Browse files
committed
Add error notifications for api response
1 parent 9ac454b commit 5165cd2

File tree

3 files changed

+268
-21
lines changed

3 files changed

+268
-21
lines changed
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import {
2+
Badge,
3+
Box,
4+
Button,
5+
Flex,
6+
Heading,
7+
IconButton,
8+
Text,
9+
useToast
10+
} from '@chakra-ui/react';
11+
import {
12+
CollecticonBell,
13+
CollecticonCircleExclamation,
14+
CollecticonXmarkSmall
15+
} from '@devseed-ui/collecticons-chakra';
16+
import React, { useEffect } from 'react';
17+
18+
interface DetailResponse {
19+
status: 400;
20+
statusText: string;
21+
detail: {
22+
detail: { loc: string[]; msg: string }[];
23+
body: any;
24+
};
25+
}
26+
27+
export type AppNotification =
28+
| {
29+
type: 'validation-error';
30+
id: number;
31+
title?: string;
32+
path: string;
33+
message: string;
34+
}
35+
| {
36+
type: 'error';
37+
id: number;
38+
title?: string;
39+
message: string;
40+
};
41+
42+
export function parseResponseForNotifications(response: DetailResponse) {
43+
if (response.status === 400 && response.detail.detail) {
44+
const notifications = response.detail.detail.reduce((acc, error, i) => {
45+
let p: string[] = error.loc.slice(1);
46+
const last = error.loc[error.loc.length - 1];
47+
48+
if (last === 'float' || last === 'int') {
49+
p = p.slice(0, -1);
50+
}
51+
52+
const path = p.join('.');
53+
54+
return acc.has(path)
55+
? acc
56+
: acc.set(path, {
57+
id: i,
58+
type: 'validation-error',
59+
path,
60+
message: error.msg
61+
});
62+
}, new Map<string, AppNotification>());
63+
64+
return Array.from(notifications.values());
65+
}
66+
return [
67+
{
68+
id: 0,
69+
type: 'error',
70+
title: `Error ${response.status}`,
71+
message: 'An error occurred: ' + response.statusText
72+
} as AppNotification
73+
];
74+
}
75+
76+
interface NotificationButtonProps {
77+
notifications: AppNotification[];
78+
}
79+
80+
function useNotificationsToast(notifications: AppNotification[]) {
81+
const toast = useToast();
82+
83+
const show = () => {
84+
if (!toast.isActive('notifications')) {
85+
toast({
86+
id: 'notifications',
87+
position: 'bottom-right',
88+
duration: null,
89+
render: () => (
90+
<NotificationBox
91+
notifications={notifications}
92+
onCloseClick={() => {
93+
toast.close('notifications');
94+
}}
95+
/>
96+
)
97+
});
98+
}
99+
};
100+
101+
useEffect(() => {
102+
if (notifications.length !== 0) {
103+
show();
104+
} else {
105+
toast.close('notifications');
106+
}
107+
}, [notifications.length]);
108+
109+
return {
110+
show,
111+
hide: () => {
112+
toast.close('notifications');
113+
}
114+
};
115+
}
116+
117+
export function NotificationButton(props: NotificationButtonProps) {
118+
const { notifications } = props;
119+
120+
const toast = useNotificationsToast(notifications);
121+
122+
return (
123+
<Button
124+
aria-label='Notifications'
125+
variant='outline'
126+
onClick={() => {
127+
toast.show();
128+
}}
129+
>
130+
<CollecticonBell />
131+
{!!notifications.length && (
132+
<Badge
133+
variant='solid'
134+
color='white'
135+
bg='base.400a'
136+
position='absolute'
137+
top='-0.5rem'
138+
right='-0.5rem'
139+
px={2}
140+
>
141+
{notifications.length < 10
142+
? `0${notifications.length}`
143+
: notifications.length}
144+
</Badge>
145+
)}
146+
</Button>
147+
);
148+
}
149+
150+
interface NotificationBoxProps {
151+
onCloseClick: () => void;
152+
notifications: AppNotification[];
153+
}
154+
155+
export function NotificationBox(props: NotificationBoxProps) {
156+
const { onCloseClick, notifications } = props;
157+
return (
158+
<Box
159+
shadow='md'
160+
borderRadius='md'
161+
bg='surface.500'
162+
w={80}
163+
overflow='hidden'
164+
>
165+
<Flex
166+
p={4}
167+
borderBottom='1px solid'
168+
borderColor='base.100'
169+
boxShadow='0 1px 0 0 rgba(0, 0, 0, 0.08)'
170+
position='relative'
171+
alignItems='center'
172+
gap={4}
173+
>
174+
<Heading size='xs'>Notifications</Heading>
175+
176+
{!!notifications.length && (
177+
<Badge variant='solid' color='white' bg='base.400a' px={2}>
178+
{' '}
179+
{notifications.length < 10
180+
? `0${notifications.length}`
181+
: notifications.length}
182+
</Badge>
183+
)}
184+
<IconButton
185+
icon={<CollecticonXmarkSmall />}
186+
aria-label='Close notifications'
187+
size='sm'
188+
variant='outline'
189+
onClick={onCloseClick}
190+
ml='auto'
191+
/>
192+
</Flex>
193+
<Box overflowY='scroll' maxH='30rem'>
194+
{notifications.length ? (
195+
notifications.map((n) => <ErrorNotification key={n.id} {...n} />)
196+
) : (
197+
<Flex height={20} alignItems='center' justifyContent='center' px={8}>
198+
Nothing to show besides this satellite 🛰️
199+
</Flex>
200+
)}
201+
</Box>
202+
</Box>
203+
);
204+
}
205+
206+
function ErrorNotification(props: AppNotification) {
207+
const { message, title } = props;
208+
209+
return (
210+
<Flex boxShadow='0 -1px 0 0 rgba(0, 0, 0, 0.08)' position='relative'>
211+
<Flex bg='danger.200' p={4}>
212+
<CollecticonCircleExclamation color='danger.500' />
213+
</Flex>
214+
<Box p={4}>
215+
<Text fontWeight='bold' mb={2}>
216+
{title || props.type === 'validation-error'
217+
? 'Validation error'
218+
: 'Error'}
219+
</Text>
220+
{props.type === 'validation-error' && (
221+
<>
222+
<Text as='span' fontWeight='bold'>
223+
At:
224+
</Text>{' '}
225+
<Text as='span' fontStyle='italic'>
226+
{props.path}
227+
</Text>
228+
<br />
229+
</>
230+
)}
231+
<Text>{message}</Text>
232+
</Box>
233+
</Flex>
234+
);
235+
}

packages/client/src/pages/CollectionForm/EditForm.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@ import {
1616

1717
import { InnerPageHeaderSticky } from '$components/InnerPageHeader';
1818
import { CollecticonForm } from '$components/icons/form';
19+
import { AppNotification, NotificationButton } from '$components/Notifications';
1920

2021
type FormView = 'fields' | 'json';
2122

2223
export function EditForm(props: {
2324
initialData?: any;
2425
onSubmit: (data: any, formikHelpers: FormikHelpers<any>) => void;
26+
notifications?: AppNotification[];
2527
}) {
26-
const { initialData, onSubmit } = props;
28+
const { initialData, onSubmit, notifications = [] } = props;
2729
const [stacData, setStacData] = useState(initialData || {});
2830

2931
const { plugins, formData, toOutData, isLoading } =
@@ -75,17 +77,18 @@ export function EditForm(props: {
7577
: 'New Collection'
7678
}
7779
actions={
78-
<>
80+
<Flex gap={4}>
81+
<NotificationButton notifications={notifications} />
7982
<Button
8083
type='submit'
8184
isDisabled={isSubmitting}
8285
colorScheme='primary'
83-
size='sm'
86+
size='md'
8487
leftIcon={<CollecticonTickSmall />}
8588
>
8689
{initialData ? 'Save' : 'Create'}
8790
</Button>
88-
</>
91+
</Flex>
8992
}
9093
/>
9194
<Flex alignItems='center' justifyContent='space-between' p={4}>

packages/client/src/pages/CollectionForm/index.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { StacCollection } from 'stac-ts';
88
import usePageTitle from '$hooks/usePageTitle';
99
import Api from 'src/api';
1010
import { EditForm } from './EditForm';
11+
import {
12+
AppNotification,
13+
parseResponseForNotifications
14+
} from '$components/Notifications';
1115

1216
export function CollectionForm() {
1317
const { collectionId } = useParams();
@@ -24,9 +28,14 @@ export function CollectionFormNew() {
2428

2529
const toast = useToast();
2630
const navigate = useNavigate();
31+
const [notifications, setNotifications] = useState<
32+
AppNotification[] | undefined
33+
>();
2734

2835
const onSubmit = async (data: any, formikHelpers: FormikHelpers<any>) => {
2936
try {
37+
toast.closeAll();
38+
setNotifications(undefined);
3039
toast({
3140
id: 'collection-submit',
3241
title: 'Creating collection...',
@@ -46,24 +55,22 @@ export function CollectionFormNew() {
4655

4756
navigate(`/collections/${data.id}`);
4857
} catch (error: any) {
49-
toast.update('collection-submit', {
50-
title: 'Collection creation failed',
51-
description: error.detail?.code,
52-
status: 'error',
53-
duration: 8000,
54-
isClosable: true
55-
});
58+
toast.close('collection-submit');
59+
setNotifications(parseResponseForNotifications(error));
5660
}
5761
formikHelpers.setSubmitting(false);
5862
};
5963

60-
return <EditForm onSubmit={onSubmit} />;
64+
return <EditForm onSubmit={onSubmit} notifications={notifications} />;
6165
}
6266

6367
export function CollectionFormEdit(props: { id: string }) {
6468
const { id } = props;
6569
const { collection, state, error } = useCollection(id);
6670
const [triedLoading, setTriedLoading] = useState(!!collection);
71+
const [notifications, setNotifications] = useState<
72+
AppNotification[] | undefined
73+
>();
6774

6875
usePageTitle(collection ? `Edit collection ${id}` : 'Edit collection');
6976

@@ -87,14 +94,15 @@ export function CollectionFormEdit(props: { id: string }) {
8794

8895
const onSubmit = async (data: any, formikHelpers: FormikHelpers<any>) => {
8996
try {
97+
toast.closeAll();
98+
setNotifications(undefined);
9099
toast({
91100
id: 'collection-submit',
92101
title: 'Updating collection...',
93102
status: 'loading',
94103
duration: null,
95104
position: 'bottom-right'
96105
});
97-
98106
await collectionTransaction().update(id, data);
99107

100108
toast.update('collection-submit', {
@@ -106,18 +114,19 @@ export function CollectionFormEdit(props: { id: string }) {
106114

107115
navigate(`/collections/${data.id}`);
108116
} catch (error: any) {
109-
toast.update('collection-submit', {
110-
title: 'Collection update failed',
111-
description: error.detail?.code,
112-
status: 'error',
113-
duration: 8000,
114-
isClosable: true
115-
});
117+
toast.close('collection-submit');
118+
setNotifications(parseResponseForNotifications(error));
116119
}
117120
formikHelpers.setSubmitting(false);
118121
};
119122

120-
return <EditForm onSubmit={onSubmit} initialData={collection} />;
123+
return (
124+
<EditForm
125+
onSubmit={onSubmit}
126+
initialData={collection}
127+
notifications={notifications}
128+
/>
129+
);
121130
}
122131

123132
type collectionTransactionType = {

0 commit comments

Comments
 (0)