Skip to content

Commit b4a7fdb

Browse files
Merge pull request #171 from VKCOM/o.shcherbakov/snackbar/QA-16708
feat(ui): add global error toast
2 parents b09b950 + 09a3a1a commit b4a7fdb

File tree

19 files changed

+310
-141
lines changed

19 files changed

+310
-141
lines changed

ui/src/api/auth/auth-client.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import axios from 'axios'
22

33
import { variablesConfig } from '@/config/variables.config'
44

5+
import { extractMessageOnErrorResponse } from '../interceptors'
6+
57
export const authClient = axios.create({
68
baseURL: variablesConfig[import.meta.env.MODE].openStfApiHostUrl,
79
withCredentials: true,
810
})
11+
12+
authClient.interceptors.response.use(
13+
(response) => response,
14+
(error) => extractMessageOnErrorResponse(error)
15+
)

ui/src/api/interceptor.ts

Lines changed: 0 additions & 23 deletions
This file was deleted.

ui/src/api/interceptors.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { authStore } from '@/store/auth-store'
2+
import { globalToast } from '@/store/global-toast'
3+
4+
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
5+
import type { ErrorResponse, UnexpectedErrorResponse } from '@/generated/types'
6+
7+
export const attachTokenOnRequest = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
8+
const { jwt } = authStore
9+
10+
config.headers.Authorization = `Bearer ${jwt}`
11+
12+
return config
13+
}
14+
15+
export const logoutOnErrorResponse = async (error: AxiosError<ErrorResponse>): Promise<AxiosError<ErrorResponse>> => {
16+
const originalRequest = error.config
17+
18+
if (originalRequest && error.response?.status === 401) {
19+
authStore.logout()
20+
}
21+
22+
return Promise.reject(error)
23+
}
24+
25+
export const extractMessageOnErrorResponse = async (
26+
error: AxiosError<UnexpectedErrorResponse | ErrorResponse>
27+
): Promise<AxiosError<UnexpectedErrorResponse | ErrorResponse>> => {
28+
const { response } = error
29+
30+
// NOTE: Errors can be filtered
31+
if (response?.data && 'description' in response.data) {
32+
globalToast.setMessage(response.data.description)
33+
}
34+
35+
if (response?.data && 'message' in response.data) {
36+
globalToast.setMessage(response.data.message)
37+
}
38+
39+
if (response) {
40+
return Promise.reject(response)
41+
}
42+
43+
return Promise.reject(error)
44+
}

ui/src/api/openstf-api/openstf-api-client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import axios from 'axios'
22

33
import { variablesConfig } from '@/config/variables.config'
44

5-
import { attachTokenOnRequest, logoutOnErrorResponse } from '../interceptor'
5+
import { attachTokenOnRequest, extractMessageOnErrorResponse, logoutOnErrorResponse } from '../interceptors'
66

77
export const openstfApiClient = axios.create({
88
baseURL: `${variablesConfig[import.meta.env.MODE].openStfApiHostUrl}/api/v1`,
@@ -11,3 +11,7 @@ export const openstfApiClient = axios.create({
1111

1212
openstfApiClient.interceptors.request.use((config) => attachTokenOnRequest(config))
1313
openstfApiClient.interceptors.response.use((response) => response, logoutOnErrorResponse)
14+
openstfApiClient.interceptors.response.use(
15+
(response) => response,
16+
(error) => extractMessageOnErrorResponse(error)
17+
)

ui/src/api/openstf/openstf-client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import axios from 'axios'
22

33
import { variablesConfig } from '@/config/variables.config'
44

5-
import { attachTokenOnRequest, logoutOnErrorResponse } from '../interceptor'
5+
import { attachTokenOnRequest, extractMessageOnErrorResponse, logoutOnErrorResponse } from '../interceptors'
66

77
export const openstfClient = axios.create({
88
baseURL: variablesConfig[import.meta.env.MODE].openStfApiHostUrl,
@@ -11,3 +11,7 @@ export const openstfClient = axios.create({
1111

1212
openstfClient.interceptors.request.use((config) => attachTokenOnRequest(config))
1313
openstfClient.interceptors.response.use((response) => response, logoutOnErrorResponse)
14+
openstfClient.interceptors.response.use(
15+
(response) => response,
16+
(error) => extractMessageOnErrorResponse(error)
17+
)

ui/src/app-wrapper.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,43 @@
1+
import { useEffect, useState } from 'react'
2+
import { observer } from 'mobx-react-lite'
3+
import { useTranslation } from 'react-i18next'
14
import { AdaptivityProvider, AppRoot, ConfigProvider } from '@vkontakte/vkui'
25

6+
import { ConditionalRender } from '@/components/lib/conditional-render'
7+
38
import { useTheme } from '@/lib/hooks/use-theme.hook'
49

10+
import { globalToast } from './store/global-toast'
11+
import { ErrorToast } from './components/lib/error-toast'
12+
513
import type { ReactNode } from 'react'
614

7-
export const AppWrapper = ({ children }: { children: ReactNode }) => {
15+
export const AppWrapper = observer(({ children }: { children: ReactNode }) => {
816
const { theme } = useTheme()
17+
const { t } = useTranslation()
18+
const [isToastVisible, setIsToastVisible] = useState(false)
19+
20+
useEffect(() => {
21+
if (globalToast.message && !isToastVisible) {
22+
setIsToastVisible(true)
23+
}
24+
}, [globalToast.message])
925

1026
return (
1127
<ConfigProvider colorScheme={theme === 'system' ? undefined : theme} platform='vkcom'>
1228
<AdaptivityProvider>
1329
<AppRoot>{children}</AppRoot>
30+
<ConditionalRender conditions={[isToastVisible]}>
31+
<ErrorToast
32+
text={globalToast.message}
33+
title={t('Error')}
34+
onClose={() => {
35+
globalToast.setMessage('')
36+
setIsToastVisible(false)
37+
}}
38+
/>
39+
</ConditionalRender>
1440
</AdaptivityProvider>
1541
</ConfigProvider>
1642
)
17-
}
43+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Input } from '@vkontakte/vkui'
2+
3+
import type { ReactNode } from 'react'
4+
5+
const emailRegex = '.+\\@.+\\..+'
6+
7+
type EmailInputProps = {
8+
value: string
9+
onChange: (value: string) => void
10+
onError: (error: string) => void
11+
placeholder?: string
12+
before?: ReactNode
13+
}
14+
15+
export const EmailInput = ({ before, value, placeholder, onChange, onError }: EmailInputProps) => (
16+
<Input
17+
before={before}
18+
placeholder={placeholder}
19+
type='email'
20+
value={value}
21+
required
22+
onChange={(event) => {
23+
onError('')
24+
25+
if (!new RegExp(emailRegex, 'i').test(event.target.value)) {
26+
onError('Invalid email')
27+
}
28+
29+
onChange(event.target.value)
30+
}}
31+
/>
32+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { EmailInput } from './email-input'
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Snackbar } from '@vkontakte/vkui'
2+
import { Icon28ErrorCircleOutline } from '@vkontakte/icons'
3+
4+
type ErrorToastProps = {
5+
title: string
6+
text: string
7+
onClose: () => void
8+
}
9+
10+
export const ErrorToast = ({ title, text, onClose }: ErrorToastProps) => (
11+
<Snackbar
12+
before={<Icon28ErrorCircleOutline fill='var(--vkui--color_accent_orange_fire)' />}
13+
placement='bottom-end'
14+
subtitle={text}
15+
onClose={onClose}
16+
>
17+
{title}
18+
</Snackbar>
19+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ErrorToast } from './error-toast'

ui/src/components/lib/list-item/list-item.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const ListItem = observer(
6565
return (
6666
<div className={styles.listItem}>
6767
<Cell
68+
activated={isOpen}
6869
after={after}
6970
before={onIsSelectedChange && <Checkbox checked={isSelected} onChange={onIsSelectedChange} />}
7071
extraSubtitle={extraSubtitle}

ui/src/components/ui/modals/create-user-modal.tsx

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import { useState } from 'react'
22
import { observer } from 'mobx-react-lite'
33
import { useTranslation } from 'react-i18next'
44
import { Icon56UserAddBadgeOutline } from '@vkontakte/icons'
5-
import { Button, FormItem, FormLayoutGroup, Input } from '@vkontakte/vkui'
5+
import { Button, FormItem, FormLayoutGroup, Input, Spacing } from '@vkontakte/vkui'
66

77
import { BaseModal } from '@/components/lib/base-modal'
8+
import { EmailInput } from '@/components/lib/email-input'
89

910
import { useCreateUser } from '@/lib/hooks/use-create-user.hook'
1011

11-
import type { ChangeEvent } from 'react'
12+
import type { ChangeEvent, FormEvent } from 'react'
1213

13-
const emailRegex = '.+\\@.+\\..+'
1414
const nameRegex = '^[0-9a-zA-Z\\-_\\. ]{1,50}$'
1515

1616
type CreateUserModalProps = {
@@ -36,17 +36,9 @@ export const CreateUserModal = observer(({ isOpen, onClose }: CreateUserModalPro
3636
setName(event.target.value)
3737
}
3838

39-
const onEmailChange = (event: ChangeEvent<HTMLInputElement>) => {
40-
setEmailError('')
39+
const onSave = (event: FormEvent<HTMLFormElement>) => {
40+
event.preventDefault()
4141

42-
if (!new RegExp(emailRegex, 'i').test(event.target.value)) {
43-
setEmailError('Invalid email')
44-
}
45-
46-
setEmail(event.target.value)
47-
}
48-
49-
const onSave = () => {
5042
createUser({ email, name })
5143

5244
setName('')
@@ -56,24 +48,31 @@ export const CreateUserModal = observer(({ isOpen, onClose }: CreateUserModalPro
5648
}
5749

5850
return (
59-
<BaseModal
60-
icon={<Icon56UserAddBadgeOutline />}
61-
isOpen={isOpen}
62-
title={t('Create new user')}
63-
actions={
64-
<Button disabled={!name || !email} mode='primary' size='l' type='submit' stretched onClick={onSave}>
65-
{t('Save')}
66-
</Button>
67-
}
68-
onClose={onClose}
69-
>
70-
<form>
51+
<BaseModal icon={<Icon56UserAddBadgeOutline />} isOpen={isOpen} title={t('Create new user')} onClose={onClose}>
52+
<form onSubmit={onSave}>
7153
<FormLayoutGroup>
7254
<FormItem bottom={nameError} status={nameError ? 'error' : undefined} top={t('Name')}>
7355
<Input placeholder='E.g. User' value={name} required onChange={onNameChange} />
7456
</FormItem>
7557
<FormItem bottom={emailError} status={emailError ? 'error' : undefined} top={t('Email')}>
76-
<Input placeholder='E.g. user@mail.com' type='email' value={email} required onChange={onEmailChange} />
58+
<EmailInput
59+
placeholder='E.g. user@mail.com'
60+
value={email}
61+
onChange={(value) => setEmail(value)}
62+
onError={(error) => setEmailError(error)}
63+
/>
64+
</FormItem>
65+
<Spacing />
66+
<FormItem>
67+
<Button
68+
disabled={!name || !email || !!nameError || !!emailError}
69+
mode='primary'
70+
size='l'
71+
type='submit'
72+
stretched
73+
>
74+
{t('Save')}
75+
</Button>
7776
</FormItem>
7877
</FormLayoutGroup>
7978
</form>

0 commit comments

Comments
 (0)