mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
Merge pull request #7290 from overleaf/ii-7154-list-user-emails
List of user emails GitOrigin-RevId: 28a8e405812932ba7ebd8043a4dc9d3c573a68b2
This commit is contained in:
parent
d50271c1e9
commit
5b0c122f5d
23 changed files with 882 additions and 24 deletions
|
@ -0,0 +1,49 @@
|
|||
import { Fragment } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import {
|
||||
UserEmailsProvider,
|
||||
useUserEmailsContext,
|
||||
} from '../context/user-email-context'
|
||||
import EmailsHeader from './emails/header'
|
||||
import EmailsRow from './emails/row'
|
||||
|
||||
function EmailsSectionContent() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
state: { data: userEmailsData },
|
||||
} = useUserEmailsContext()
|
||||
const userEmails = Object.values(userEmailsData.byId)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{t('emails_and_affiliations_title')}</h3>
|
||||
<p className="small">{t('emails_and_affiliations_explanation')}</p>
|
||||
<p className="small">
|
||||
<Trans i18nKey="change_primary_email_address_instructions">
|
||||
<strong />
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-has-content */}
|
||||
<a href="/learn/how-to/Keeping_your_account_secure" />
|
||||
</Trans>
|
||||
</p>
|
||||
<EmailsHeader />
|
||||
{userEmails?.map((userEmail, i) => (
|
||||
<Fragment key={userEmail.email}>
|
||||
<EmailsRow userEmailData={userEmail} />
|
||||
{i + 1 !== userEmails.length && (
|
||||
<div className="horizontal-divider" />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EmailsSection() {
|
||||
return (
|
||||
<UserEmailsProvider>
|
||||
<EmailsSectionContent />
|
||||
</UserEmailsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailsSection
|
|
@ -0,0 +1,9 @@
|
|||
type CellProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function Cell({ children }: CellProps) {
|
||||
return <div className="affiliations-table-cell">{children}</div>
|
||||
}
|
||||
|
||||
export default Cell
|
|
@ -0,0 +1,41 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { UserEmailData } from '../../../../../../types/user-email'
|
||||
import ResendConfirmationEmailButton from './resend-confirmation-email-button'
|
||||
|
||||
type EmailProps = {
|
||||
userEmailData: UserEmailData
|
||||
}
|
||||
|
||||
function Email({ userEmailData }: EmailProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
{userEmailData.email}
|
||||
{userEmailData.default ? ' (primary)' : ''}
|
||||
{!userEmailData.confirmedAt && (
|
||||
<div className="small">
|
||||
<strong>
|
||||
{t('unconfirmed')}.
|
||||
{!userEmailData.ssoAvailable && (
|
||||
<span> {t('please_check_your_inbox')}.</span>
|
||||
)}
|
||||
</strong>
|
||||
<br />
|
||||
{!userEmailData.ssoAvailable && (
|
||||
<ResendConfirmationEmailButton email={userEmailData.email} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{userEmailData.confirmedAt &&
|
||||
userEmailData.affiliation?.institution.confirmed &&
|
||||
userEmailData.affiliation?.licence !== 'free' && (
|
||||
<div className="small">
|
||||
<span className="label label-primary">{t('professional')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Email
|
|
@ -0,0 +1,33 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Row, Col } from 'react-bootstrap'
|
||||
import EmailCell from './cell'
|
||||
|
||||
function Header() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col sm={5} className="hidden-xs">
|
||||
<EmailCell>
|
||||
<strong>{t('email')}</strong>
|
||||
</EmailCell>
|
||||
</Col>
|
||||
<Col sm={5} className="hidden-xs">
|
||||
<EmailCell>
|
||||
<strong>{t('institution_and_role')}</strong>
|
||||
</EmailCell>
|
||||
</Col>
|
||||
<Col sm={2} className="hidden-xs">
|
||||
<EmailCell>
|
||||
<strong>todo</strong>
|
||||
</EmailCell>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="hidden-xs horizontal-divider" />
|
||||
<div className="hidden-xs horizontal-divider" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
|
@ -0,0 +1,62 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import useAsync from '../../../../shared/hooks/use-async'
|
||||
import { postJSON } from '../../../../infrastructure/fetch-json'
|
||||
import { UserEmailData } from '../../../../../../types/user-email'
|
||||
import { useUserEmailsContext } from '../../context/user-email-context'
|
||||
|
||||
type ResendConfirmationEmailButtonProps = {
|
||||
email: UserEmailData['email']
|
||||
}
|
||||
|
||||
function ResendConfirmationEmailButton({
|
||||
email,
|
||||
}: ResendConfirmationEmailButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { isLoading, isError, runAsync } = useAsync()
|
||||
const { setLoading } = useUserEmailsContext()
|
||||
|
||||
// Update global isLoading prop
|
||||
useEffect(() => {
|
||||
setLoading(isLoading)
|
||||
}, [setLoading, isLoading])
|
||||
|
||||
const handleResendConfirmationEmail = () => {
|
||||
runAsync(
|
||||
postJSON('/user/emails/resend_confirmation', {
|
||||
body: {
|
||||
email,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Icon type="refresh" spin fw /> {t('sending')}...
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-inline-link"
|
||||
onClick={handleResendConfirmationEmail}
|
||||
>
|
||||
{t('resend_confirmation_email')}
|
||||
</button>
|
||||
<br />
|
||||
{isError && (
|
||||
<span className="text-danger">
|
||||
<Icon type="exclamation-triangle" fw />{' '}
|
||||
{t('error_performing_request')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResendConfirmationEmailButton
|
|
@ -0,0 +1,28 @@
|
|||
import { UserEmailData } from '../../../../../../types/user-email'
|
||||
import { Row, Col } from 'react-bootstrap'
|
||||
import Email from './email'
|
||||
import EmailCell from './cell'
|
||||
|
||||
type EmailsRowProps = {
|
||||
userEmailData: UserEmailData
|
||||
}
|
||||
|
||||
function EmailsRow({ userEmailData }: EmailsRowProps) {
|
||||
return (
|
||||
<Row>
|
||||
<Col sm={5}>
|
||||
<EmailCell>
|
||||
<Email userEmailData={userEmailData} />
|
||||
</EmailCell>
|
||||
</Col>
|
||||
<Col sm={5}>
|
||||
<EmailCell>todo</EmailCell>
|
||||
</Col>
|
||||
<Col sm={2}>
|
||||
<EmailCell>todo</EmailCell>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailsRow
|
|
@ -0,0 +1,105 @@
|
|||
import { createContext, useContext, useReducer, useCallback } from 'react'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import useSafeDispatch from '../../../shared/hooks/use-safe-dispatch'
|
||||
import { UserEmailData } from '../../../../../types/user-email'
|
||||
import { normalize, NormalizedObject } from '../../../utils/normalize'
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
enum Actions {
|
||||
SET_LOADING_STATE = 'SET_LOADING_STATE', // eslint-disable-line no-unused-vars
|
||||
}
|
||||
|
||||
type ActionSetLoading = {
|
||||
type: Actions.SET_LOADING_STATE
|
||||
payload: boolean
|
||||
}
|
||||
|
||||
type State = {
|
||||
isLoading: boolean
|
||||
data: {
|
||||
byId: NormalizedObject<UserEmailData>
|
||||
}
|
||||
}
|
||||
|
||||
type Action = ActionSetLoading
|
||||
|
||||
const setLoadingAction = (state: State, action: ActionSetLoading) => ({
|
||||
...state,
|
||||
isLoading: action.payload,
|
||||
})
|
||||
|
||||
const initialState: State = {
|
||||
isLoading: false,
|
||||
data: {
|
||||
byId: {},
|
||||
},
|
||||
}
|
||||
|
||||
const reducer = (state: State, action: Action) => {
|
||||
switch (action.type) {
|
||||
case Actions.SET_LOADING_STATE:
|
||||
return setLoadingAction(state, action)
|
||||
}
|
||||
}
|
||||
|
||||
const initializer = (initialState: State) => {
|
||||
const normalized = normalize<UserEmailData>(getMeta('ol-userEmails'), {
|
||||
idAttribute: 'email',
|
||||
})
|
||||
const byId = normalized || {}
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
data: {
|
||||
...initialState.data,
|
||||
byId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function useUserEmails() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState, initializer)
|
||||
const safeDispatch = useSafeDispatch(dispatch)
|
||||
|
||||
const setLoading = useCallback(
|
||||
(flag: boolean) => {
|
||||
safeDispatch({
|
||||
type: Actions.SET_LOADING_STATE,
|
||||
payload: flag,
|
||||
})
|
||||
},
|
||||
[safeDispatch]
|
||||
)
|
||||
|
||||
return {
|
||||
state,
|
||||
setLoading,
|
||||
}
|
||||
}
|
||||
|
||||
const UserEmailsContext = createContext<
|
||||
ReturnType<typeof useUserEmails> | undefined
|
||||
>(undefined)
|
||||
UserEmailsContext.displayName = 'UserEmailsContext'
|
||||
|
||||
type UserEmailsProviderProps = {
|
||||
children: React.ReactNode
|
||||
} & Record<string, unknown>
|
||||
|
||||
function UserEmailsProvider(props: UserEmailsProviderProps) {
|
||||
const value = useUserEmails()
|
||||
|
||||
return <UserEmailsContext.Provider value={value} {...props} />
|
||||
}
|
||||
|
||||
const useUserEmailsContext = () => {
|
||||
const context = useContext(UserEmailsContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useUserEmailsContext must be used in a UserEmailsProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export { UserEmailsProvider, useUserEmailsContext }
|
|
@ -1,6 +1,14 @@
|
|||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
|
||||
type IconProps = {
|
||||
type: string
|
||||
spin?: boolean
|
||||
fw?: boolean
|
||||
modifier?: string
|
||||
className?: string
|
||||
accessibilityLabel?: string
|
||||
}
|
||||
|
||||
function Icon({
|
||||
type,
|
||||
spin,
|
||||
|
@ -8,7 +16,7 @@ function Icon({
|
|||
modifier,
|
||||
className = '',
|
||||
accessibilityLabel,
|
||||
}) {
|
||||
}: IconProps) {
|
||||
const iconClassName = classNames(
|
||||
'fa',
|
||||
`fa-${type}`,
|
||||
|
@ -30,13 +38,4 @@ function Icon({
|
|||
)
|
||||
}
|
||||
|
||||
Icon.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
spin: PropTypes.bool,
|
||||
fw: PropTypes.bool,
|
||||
modifier: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
accessibilityLabel: PropTypes.string,
|
||||
}
|
||||
|
||||
export default Icon
|
58
services/web/frontend/js/shared/hooks/use-async.ts
Normal file
58
services/web/frontend/js/shared/hooks/use-async.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import * as React from 'react'
|
||||
import useSafeDispatch from './use-safe-dispatch'
|
||||
import { Nullable } from '../../../../types/utils'
|
||||
|
||||
type State = {
|
||||
status: 'idle' | 'pending' | 'resolved' | 'rejected'
|
||||
data: Nullable<unknown>
|
||||
error: Nullable<Record<string, unknown>>
|
||||
}
|
||||
type Action = Partial<State>
|
||||
|
||||
const defaultInitialState: State = { status: 'idle', data: null, error: null }
|
||||
const initializer = (initialState: State) => ({ ...initialState })
|
||||
|
||||
function useAsync(initialState?: Partial<State>) {
|
||||
const [{ status, data, error }, setState] = React.useReducer(
|
||||
(state: State, action: Action) => ({ ...state, ...action }),
|
||||
{ ...defaultInitialState, ...initialState },
|
||||
initializer
|
||||
)
|
||||
|
||||
const safeSetState = useSafeDispatch(setState)
|
||||
|
||||
const setData = React.useCallback(
|
||||
data => safeSetState({ data, status: 'resolved' }),
|
||||
[safeSetState]
|
||||
)
|
||||
|
||||
const setError = React.useCallback(
|
||||
error => safeSetState({ error, status: 'rejected' }),
|
||||
[safeSetState]
|
||||
)
|
||||
|
||||
const runAsync = React.useCallback(
|
||||
(promise: Promise<Record<string, unknown>>) => {
|
||||
safeSetState({ status: 'pending' })
|
||||
|
||||
return promise.then(setData, setError)
|
||||
},
|
||||
[safeSetState, setData, setError]
|
||||
)
|
||||
|
||||
return {
|
||||
isIdle: status === 'idle',
|
||||
isLoading: status === 'pending',
|
||||
isError: status === 'rejected',
|
||||
isSuccess: status === 'resolved',
|
||||
setData,
|
||||
setError,
|
||||
error,
|
||||
status,
|
||||
data,
|
||||
runAsync,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAsync
|
||||
export type UseAsyncReturnType = ReturnType<typeof useAsync>
|
|
@ -1,13 +0,0 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export default function useIsMounted() {
|
||||
const isMounted = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
return isMounted
|
||||
}
|
14
services/web/frontend/js/shared/hooks/use-is-mounted.ts
Normal file
14
services/web/frontend/js/shared/hooks/use-is-mounted.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { useLayoutEffect, useRef } from 'react'
|
||||
|
||||
export default function useIsMounted() {
|
||||
const mounted = useRef(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
mounted.current = true
|
||||
return () => {
|
||||
mounted.current = false
|
||||
}
|
||||
}, [mounted])
|
||||
|
||||
return mounted
|
||||
}
|
17
services/web/frontend/js/shared/hooks/use-safe-dispatch.ts
Normal file
17
services/web/frontend/js/shared/hooks/use-safe-dispatch.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import * as React from 'react'
|
||||
import useIsMounted from './use-is-mounted'
|
||||
|
||||
function useSafeDispatch<T>(dispatch: React.Dispatch<T>) {
|
||||
const mounted = useIsMounted()
|
||||
|
||||
return React.useCallback<(args: T) => void>(
|
||||
action => {
|
||||
if (mounted.current) {
|
||||
dispatch(action)
|
||||
}
|
||||
},
|
||||
[dispatch, mounted]
|
||||
) as React.Dispatch<T>
|
||||
}
|
||||
|
||||
export default useSafeDispatch
|
22
services/web/frontend/js/utils/normalize.ts
Normal file
22
services/web/frontend/js/utils/normalize.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import mapKeys from 'lodash/mapKeys'
|
||||
|
||||
export interface NormalizedObject<T> {
|
||||
[p: string]: T
|
||||
}
|
||||
|
||||
type Data<T> = T[]
|
||||
type Config = Partial<{
|
||||
idAttribute: string
|
||||
}>
|
||||
|
||||
export function normalize<T>(
|
||||
data: Data<T>,
|
||||
config: Config = {}
|
||||
): NormalizedObject<T> | undefined {
|
||||
const { idAttribute = 'id' } = config
|
||||
const mapped = mapKeys(data, idAttribute)
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(mapped, 'undefined')
|
||||
? undefined
|
||||
: mapped
|
||||
}
|
75
services/web/frontend/stories/settings.stories.js
Normal file
75
services/web/frontend/stories/settings.stories.js
Normal file
|
@ -0,0 +1,75 @@
|
|||
import EmailsSection from '../js/features/settings/components/emails-section'
|
||||
import useFetchMock from './hooks/use-fetch-mock'
|
||||
|
||||
const MOCK_DELAY = 1000
|
||||
window.metaAttributesCache = window.metaAttributesCache || new Map()
|
||||
|
||||
function defaultSetupMocks(fetchMock) {
|
||||
fetchMock.post(
|
||||
/\/user\/emails\/resend_confirmation/,
|
||||
(path, req) => {
|
||||
return 200
|
||||
},
|
||||
{
|
||||
delay: MOCK_DELAY,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const fakeUsersData = [
|
||||
{
|
||||
affiliation: {
|
||||
institution: {
|
||||
confirmed: true,
|
||||
},
|
||||
licence: 'pro_plus',
|
||||
},
|
||||
confirmedAt: '2022-03-09T10:59:44.139Z',
|
||||
email: 'foo@overleaf.com',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
confirmedAt: '2022-03-10T10:59:44.139Z',
|
||||
email: 'bar@overleaf.com',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
email: 'baz@overleaf.com',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
email: 'qux@overleaf.com',
|
||||
default: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const EmailsList = args => {
|
||||
useFetchMock(defaultSetupMocks)
|
||||
window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
|
||||
|
||||
return <EmailsSection {...args} />
|
||||
}
|
||||
|
||||
export const NetworkErrors = args => {
|
||||
useFetchMock(defaultSetupMocks)
|
||||
window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
|
||||
|
||||
useFetchMock(fetchMock => {
|
||||
fetchMock.post(
|
||||
/\/user\/emails\/resend_confirmation/,
|
||||
() => {
|
||||
return 503
|
||||
},
|
||||
{
|
||||
delay: MOCK_DELAY,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return <EmailsSection {...args} />
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Emails and Affiliations',
|
||||
component: EmailsSection,
|
||||
}
|
|
@ -58,6 +58,7 @@
|
|||
@import 'components/infinite-scroll.less';
|
||||
@import 'components/expand-collapse.less';
|
||||
@import 'components/beta-badges.less';
|
||||
@import 'components/divider.less';
|
||||
|
||||
// Components w/ JavaScript
|
||||
@import 'components/modals.less';
|
||||
|
|
|
@ -23,6 +23,9 @@
|
|||
.affiliations-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
.affiliations-table-cell {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.affiliations-table-email {
|
||||
width: 40%;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.horizontal-divider {
|
||||
border-top: 1px solid @table-border-color;
|
||||
}
|
|
@ -0,0 +1,159 @@
|
|||
import {
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
fireEvent,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react'
|
||||
import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
const confirmedUserData = {
|
||||
confirmedAt: '2022-03-10T10:59:44.139Z',
|
||||
email: 'bar@overleaf.com',
|
||||
default: false,
|
||||
}
|
||||
|
||||
const unconfirmedUserData = {
|
||||
email: 'baz@overleaf.com',
|
||||
default: false,
|
||||
}
|
||||
|
||||
const professionalUserData = {
|
||||
affiliation: {
|
||||
institution: {
|
||||
confirmed: true,
|
||||
},
|
||||
licence: 'pro_plus',
|
||||
},
|
||||
confirmedAt: '2022-03-09T10:59:44.139Z',
|
||||
email: 'foo@overleaf.com',
|
||||
default: true,
|
||||
}
|
||||
|
||||
const fakeUsersData = [
|
||||
{ ...confirmedUserData },
|
||||
{ ...unconfirmedUserData },
|
||||
{ ...professionalUserData },
|
||||
]
|
||||
|
||||
describe('<EmailsSection />', function () {
|
||||
afterEach(function () {
|
||||
window.metaAttributesCache = new Map()
|
||||
fetchMock.reset()
|
||||
})
|
||||
|
||||
it('renders translated heading', function () {
|
||||
window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
|
||||
render(<EmailsSection />)
|
||||
|
||||
screen.getByRole('heading', { name: /emails and affiliations/i })
|
||||
})
|
||||
|
||||
it('renders translated description', function () {
|
||||
window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
|
||||
render(<EmailsSection />)
|
||||
|
||||
screen.getByText(/add additional email addresses/i)
|
||||
screen.getByText(/to change your primary email/i)
|
||||
})
|
||||
|
||||
it('renders user emails', function () {
|
||||
window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
|
||||
render(<EmailsSection />)
|
||||
|
||||
fakeUsersData.forEach(userData => {
|
||||
screen.getByText(new RegExp(userData.email, 'i'))
|
||||
})
|
||||
})
|
||||
|
||||
it('renders primary status', function () {
|
||||
window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
|
||||
render(<EmailsSection />)
|
||||
|
||||
const primary = fakeUsersData.find(userData => userData.default)
|
||||
|
||||
screen.getByText(`${primary.email} (primary)`)
|
||||
})
|
||||
|
||||
it('shows confirmation status for unconfirmed users', function () {
|
||||
window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
|
||||
render(<EmailsSection />)
|
||||
|
||||
screen.getByText(/please check your inbox/i)
|
||||
})
|
||||
|
||||
it('hides confirmation status for confirmed users', function () {
|
||||
window.metaAttributesCache.set('ol-userEmails', [confirmedUserData])
|
||||
render(<EmailsSection />)
|
||||
|
||||
expect(screen.queryByText(/please check your inbox/i)).to.be.null
|
||||
})
|
||||
|
||||
it('renders resend link', function () {
|
||||
window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
|
||||
render(<EmailsSection />)
|
||||
|
||||
screen.getByRole('button', { name: /resend confirmation email/i })
|
||||
})
|
||||
|
||||
it('renders professional label', function () {
|
||||
window.metaAttributesCache.set('ol-userEmails', [professionalUserData])
|
||||
render(<EmailsSection />)
|
||||
|
||||
const node = screen.getByText(professionalUserData.email, {
|
||||
exact: false,
|
||||
})
|
||||
expect(within(node).getByText(/professional/i)).to.exist
|
||||
})
|
||||
|
||||
it('shows loader when resending email', async function () {
|
||||
fetchMock.post('/user/emails/resend_confirmation', 200)
|
||||
window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
|
||||
render(<EmailsSection />)
|
||||
|
||||
const button = screen.getByRole('button', {
|
||||
name: /resend confirmation email/i,
|
||||
})
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: /resend confirmation email/i,
|
||||
})
|
||||
).to.be.null
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
|
||||
|
||||
expect(
|
||||
screen.queryByText(/an error has occurred while performing your request/i)
|
||||
).to.be.null
|
||||
|
||||
await screen.findByRole('button', {
|
||||
name: /resend confirmation email/i,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when resending email fails', async function () {
|
||||
fetchMock.post('/user/emails/resend_confirmation', 503)
|
||||
window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
|
||||
render(<EmailsSection />)
|
||||
|
||||
const button = screen.getByRole('button', {
|
||||
name: /resend confirmation email/i,
|
||||
})
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: /resend confirmation email/i,
|
||||
})
|
||||
).to.be.null
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
|
||||
|
||||
screen.getByText(/an error has occurred while performing your request/i)
|
||||
screen.getByRole('button', { name: /resend confirmation email/i })
|
||||
})
|
||||
})
|
176
services/web/test/frontend/shared/hooks/use-async.test.ts
Normal file
176
services/web/test/frontend/shared/hooks/use-async.test.ts
Normal file
|
@ -0,0 +1,176 @@
|
|||
import { renderHook, act } from '@testing-library/react-hooks'
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import useAsync from '../../../../frontend/js/shared/hooks/use-async'
|
||||
|
||||
function deferred() {
|
||||
let res!: (
|
||||
value: Record<string, unknown> | PromiseLike<Record<string, unknown>>
|
||||
) => void
|
||||
let rej!: (reason?: any) => void
|
||||
|
||||
const promise = new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
res = resolve
|
||||
rej = reject
|
||||
})
|
||||
|
||||
return { promise, resolve: res, reject: rej }
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
status: 'idle',
|
||||
data: null,
|
||||
error: null,
|
||||
|
||||
isIdle: true,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
}
|
||||
|
||||
const pendingState = {
|
||||
...defaultState,
|
||||
status: 'pending',
|
||||
isIdle: false,
|
||||
isLoading: true,
|
||||
}
|
||||
|
||||
const resolvedState = {
|
||||
...defaultState,
|
||||
status: 'resolved',
|
||||
isIdle: false,
|
||||
isSuccess: true,
|
||||
}
|
||||
|
||||
const rejectedState = {
|
||||
...defaultState,
|
||||
status: 'rejected',
|
||||
isIdle: false,
|
||||
isError: true,
|
||||
}
|
||||
|
||||
describe('useAsync', function () {
|
||||
beforeEach(function () {
|
||||
global.console.error = sinon.stub()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
global.console.error.reset()
|
||||
})
|
||||
|
||||
it('exposes the methods', function () {
|
||||
const { result } = renderHook(() => useAsync())
|
||||
|
||||
expect(result.current.setData).to.be.a('function')
|
||||
expect(result.current.setError).to.be.a('function')
|
||||
expect(result.current.runAsync).to.be.a('function')
|
||||
})
|
||||
|
||||
it('calling `runAsync` with a promise which resolves', async function () {
|
||||
const { promise, resolve } = deferred()
|
||||
const { result } = renderHook(() => useAsync())
|
||||
|
||||
expect(result.current).to.include(defaultState)
|
||||
|
||||
let p: Promise<unknown>
|
||||
act(() => {
|
||||
p = result.current.runAsync(promise)
|
||||
})
|
||||
|
||||
expect(result.current).to.include(pendingState)
|
||||
|
||||
const resolvedValue = {}
|
||||
await act(async () => {
|
||||
resolve(resolvedValue)
|
||||
await p
|
||||
})
|
||||
|
||||
expect(result.current).to.include({
|
||||
...resolvedState,
|
||||
data: resolvedValue,
|
||||
})
|
||||
})
|
||||
|
||||
it('calling `runAsync` with a promise which rejects', async function () {
|
||||
const { promise, reject } = deferred()
|
||||
const { result } = renderHook(() => useAsync())
|
||||
|
||||
expect(result.current).to.include(defaultState)
|
||||
|
||||
let p: Promise<unknown>
|
||||
act(() => {
|
||||
p = result.current.runAsync(promise)
|
||||
})
|
||||
|
||||
expect(result.current).to.include(pendingState)
|
||||
|
||||
const rejectedValue = Symbol('rejected value')
|
||||
await act(async () => {
|
||||
reject(rejectedValue)
|
||||
await p
|
||||
})
|
||||
|
||||
expect(result.current).to.include({
|
||||
...rejectedState,
|
||||
error: rejectedValue,
|
||||
})
|
||||
})
|
||||
|
||||
it('can specify an initial state', function () {
|
||||
const mockData = Symbol('resolved value')
|
||||
const customInitialState = { status: 'resolved' as const, data: mockData }
|
||||
const { result } = renderHook(() => useAsync(customInitialState))
|
||||
|
||||
expect(result.current).to.include({
|
||||
...resolvedState,
|
||||
...customInitialState,
|
||||
})
|
||||
})
|
||||
|
||||
it('can set the data', function () {
|
||||
const mockData = Symbol('resolved value')
|
||||
const { result } = renderHook(() => useAsync())
|
||||
|
||||
act(() => {
|
||||
result.current.setData(mockData)
|
||||
})
|
||||
|
||||
expect(result.current).to.include({
|
||||
...resolvedState,
|
||||
data: mockData,
|
||||
})
|
||||
})
|
||||
|
||||
it('can set the error', function () {
|
||||
const mockError = Symbol('rejected value')
|
||||
const { result } = renderHook(() => useAsync())
|
||||
|
||||
act(() => {
|
||||
result.current.setError(mockError)
|
||||
})
|
||||
|
||||
expect(result.current).to.include({
|
||||
...rejectedState,
|
||||
error: mockError,
|
||||
})
|
||||
})
|
||||
|
||||
it('no state updates happen if the component is unmounted while pending', async function () {
|
||||
const { promise, resolve } = deferred()
|
||||
const { result, unmount } = renderHook(() => useAsync())
|
||||
|
||||
let p: Promise<unknown>
|
||||
act(() => {
|
||||
p = result.current.runAsync(promise)
|
||||
})
|
||||
unmount()
|
||||
await act(async () => {
|
||||
resolve({})
|
||||
await p
|
||||
})
|
||||
|
||||
expect(global.console.error).not.to.have.been.called
|
||||
})
|
||||
})
|
6
services/web/types/affiliation.ts
Normal file
6
services/web/types/affiliation.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { Institution } from './institution'
|
||||
|
||||
export type Affiliation = {
|
||||
institution: Institution
|
||||
licence?: 'free' | 'pro_plus'
|
||||
}
|
1
services/web/types/institution.ts
Normal file
1
services/web/types/institution.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type Institution = Record<string, unknown>
|
9
services/web/types/user-email.ts
Normal file
9
services/web/types/user-email.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Affiliation } from './affiliation'
|
||||
|
||||
export type UserEmailData = {
|
||||
affiliation?: Affiliation
|
||||
confirmedAt: string
|
||||
email: string
|
||||
default: boolean
|
||||
ssoAvailable?: boolean
|
||||
}
|
1
services/web/types/utils.ts
Normal file
1
services/web/types/utils.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type Nullable<T> = T | null
|
Loading…
Reference in a new issue