| 
 | 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 | +}  | 
0 commit comments