From 5b0c122f5dd565386a63d4f3b20adcaa04e1e54c Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Fri, 8 Apr 2022 14:00:46 +0300 Subject: [PATCH] Merge pull request #7290 from overleaf/ii-7154-list-user-emails List of user emails GitOrigin-RevId: 28a8e405812932ba7ebd8043a4dc9d3c573a68b2 --- .../settings/components/emails-section.tsx | 49 +++++ .../settings/components/emails/cell.tsx | 9 + .../settings/components/emails/email.tsx | 41 ++++ .../settings/components/emails/header.tsx | 33 ++++ .../resend-confirmation-email-button.tsx | 62 ++++++ .../settings/components/emails/row.tsx | 28 +++ .../settings/context/user-email-context.tsx | 105 +++++++++++ .../shared/components/{icon.js => icon.tsx} | 21 +-- .../web/frontend/js/shared/hooks/use-async.ts | 58 ++++++ .../js/shared/hooks/use-is-mounted.js | 13 -- .../js/shared/hooks/use-is-mounted.ts | 14 ++ .../js/shared/hooks/use-safe-dispatch.ts | 17 ++ services/web/frontend/js/utils/normalize.ts | 22 +++ .../web/frontend/stories/settings.stories.js | 75 ++++++++ .../frontend/stylesheets/_style_includes.less | 1 + .../stylesheets/app/account-settings.less | 3 + .../stylesheets/components/divider.less | 3 + .../components/emails/emails-section.test.tsx | 159 ++++++++++++++++ .../frontend/shared/hooks/use-async.test.ts | 176 ++++++++++++++++++ services/web/types/affiliation.ts | 6 + services/web/types/institution.ts | 1 + services/web/types/user-email.ts | 9 + services/web/types/utils.ts | 1 + 23 files changed, 882 insertions(+), 24 deletions(-) create mode 100644 services/web/frontend/js/features/settings/components/emails-section.tsx create mode 100644 services/web/frontend/js/features/settings/components/emails/cell.tsx create mode 100644 services/web/frontend/js/features/settings/components/emails/email.tsx create mode 100644 services/web/frontend/js/features/settings/components/emails/header.tsx create mode 100644 services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx create mode 100644 services/web/frontend/js/features/settings/components/emails/row.tsx create mode 100644 services/web/frontend/js/features/settings/context/user-email-context.tsx rename services/web/frontend/js/shared/components/{icon.js => icon.tsx} (67%) create mode 100644 services/web/frontend/js/shared/hooks/use-async.ts delete mode 100644 services/web/frontend/js/shared/hooks/use-is-mounted.js create mode 100644 services/web/frontend/js/shared/hooks/use-is-mounted.ts create mode 100644 services/web/frontend/js/shared/hooks/use-safe-dispatch.ts create mode 100644 services/web/frontend/js/utils/normalize.ts create mode 100644 services/web/frontend/stories/settings.stories.js create mode 100644 services/web/frontend/stylesheets/components/divider.less create mode 100644 services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx create mode 100644 services/web/test/frontend/shared/hooks/use-async.test.ts create mode 100644 services/web/types/affiliation.ts create mode 100644 services/web/types/institution.ts create mode 100644 services/web/types/user-email.ts create mode 100644 services/web/types/utils.ts diff --git a/services/web/frontend/js/features/settings/components/emails-section.tsx b/services/web/frontend/js/features/settings/components/emails-section.tsx new file mode 100644 index 0000000000..5dde2bf56f --- /dev/null +++ b/services/web/frontend/js/features/settings/components/emails-section.tsx @@ -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 ( + <> +

{t('emails_and_affiliations_title')}

+

{t('emails_and_affiliations_explanation')}

+

+ + + {/* eslint-disable-next-line jsx-a11y/anchor-has-content */} + + +

+ + {userEmails?.map((userEmail, i) => ( + + + {i + 1 !== userEmails.length && ( +
+ )} + + ))} + + ) +} + +function EmailsSection() { + return ( + + + + ) +} + +export default EmailsSection diff --git a/services/web/frontend/js/features/settings/components/emails/cell.tsx b/services/web/frontend/js/features/settings/components/emails/cell.tsx new file mode 100644 index 0000000000..de0f71ac58 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/emails/cell.tsx @@ -0,0 +1,9 @@ +type CellProps = { + children: React.ReactNode +} + +function Cell({ children }: CellProps) { + return
{children}
+} + +export default Cell diff --git a/services/web/frontend/js/features/settings/components/emails/email.tsx b/services/web/frontend/js/features/settings/components/emails/email.tsx new file mode 100644 index 0000000000..e895e484f7 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/emails/email.tsx @@ -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 && ( +
+ + {t('unconfirmed')}. + {!userEmailData.ssoAvailable && ( + {t('please_check_your_inbox')}. + )} + +
+ {!userEmailData.ssoAvailable && ( + + )} +
+ )} + {userEmailData.confirmedAt && + userEmailData.affiliation?.institution.confirmed && + userEmailData.affiliation?.licence !== 'free' && ( +
+ {t('professional')} +
+ )} + + ) +} + +export default Email diff --git a/services/web/frontend/js/features/settings/components/emails/header.tsx b/services/web/frontend/js/features/settings/components/emails/header.tsx new file mode 100644 index 0000000000..a3a3a56431 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/emails/header.tsx @@ -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 ( + <> + + + + {t('email')} + + + + + {t('institution_and_role')} + + + + + todo + + + +
+
+ + ) +} + +export default Header diff --git a/services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx b/services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx new file mode 100644 index 0000000000..9cde7bb097 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx @@ -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 ( + <> + {t('sending')}... + + ) + } + + return ( + <> + +
+ {isError && ( + + {' '} + {t('error_performing_request')} + + )} + + ) +} + +export default ResendConfirmationEmailButton diff --git a/services/web/frontend/js/features/settings/components/emails/row.tsx b/services/web/frontend/js/features/settings/components/emails/row.tsx new file mode 100644 index 0000000000..791c9bc8f0 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/emails/row.tsx @@ -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 ( + + + + + + + + todo + + + todo + + + ) +} + +export default EmailsRow diff --git a/services/web/frontend/js/features/settings/context/user-email-context.tsx b/services/web/frontend/js/features/settings/context/user-email-context.tsx new file mode 100644 index 0000000000..f2fdfac0de --- /dev/null +++ b/services/web/frontend/js/features/settings/context/user-email-context.tsx @@ -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 + } +} + +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(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 | undefined +>(undefined) +UserEmailsContext.displayName = 'UserEmailsContext' + +type UserEmailsProviderProps = { + children: React.ReactNode +} & Record + +function UserEmailsProvider(props: UserEmailsProviderProps) { + const value = useUserEmails() + + return +} + +const useUserEmailsContext = () => { + const context = useContext(UserEmailsContext) + + if (context === undefined) { + throw new Error('useUserEmailsContext must be used in a UserEmailsProvider') + } + + return context +} + +export { UserEmailsProvider, useUserEmailsContext } diff --git a/services/web/frontend/js/shared/components/icon.js b/services/web/frontend/js/shared/components/icon.tsx similarity index 67% rename from services/web/frontend/js/shared/components/icon.js rename to services/web/frontend/js/shared/components/icon.tsx index 353e1fa3d2..fe4471fb96 100644 --- a/services/web/frontend/js/shared/components/icon.js +++ b/services/web/frontend/js/shared/components/icon.tsx @@ -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 diff --git a/services/web/frontend/js/shared/hooks/use-async.ts b/services/web/frontend/js/shared/hooks/use-async.ts new file mode 100644 index 0000000000..707a4290f3 --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-async.ts @@ -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 + error: Nullable> +} +type Action = Partial + +const defaultInitialState: State = { status: 'idle', data: null, error: null } +const initializer = (initialState: State) => ({ ...initialState }) + +function useAsync(initialState?: Partial) { + 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>) => { + 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 diff --git a/services/web/frontend/js/shared/hooks/use-is-mounted.js b/services/web/frontend/js/shared/hooks/use-is-mounted.js deleted file mode 100644 index 3fe667c3db..0000000000 --- a/services/web/frontend/js/shared/hooks/use-is-mounted.js +++ /dev/null @@ -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 -} diff --git a/services/web/frontend/js/shared/hooks/use-is-mounted.ts b/services/web/frontend/js/shared/hooks/use-is-mounted.ts new file mode 100644 index 0000000000..b207ee79de --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-is-mounted.ts @@ -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 +} diff --git a/services/web/frontend/js/shared/hooks/use-safe-dispatch.ts b/services/web/frontend/js/shared/hooks/use-safe-dispatch.ts new file mode 100644 index 0000000000..726da8ccd1 --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-safe-dispatch.ts @@ -0,0 +1,17 @@ +import * as React from 'react' +import useIsMounted from './use-is-mounted' + +function useSafeDispatch(dispatch: React.Dispatch) { + const mounted = useIsMounted() + + return React.useCallback<(args: T) => void>( + action => { + if (mounted.current) { + dispatch(action) + } + }, + [dispatch, mounted] + ) as React.Dispatch +} + +export default useSafeDispatch diff --git a/services/web/frontend/js/utils/normalize.ts b/services/web/frontend/js/utils/normalize.ts new file mode 100644 index 0000000000..05ee6835c3 --- /dev/null +++ b/services/web/frontend/js/utils/normalize.ts @@ -0,0 +1,22 @@ +import mapKeys from 'lodash/mapKeys' + +export interface NormalizedObject { + [p: string]: T +} + +type Data = T[] +type Config = Partial<{ + idAttribute: string +}> + +export function normalize( + data: Data, + config: Config = {} +): NormalizedObject | undefined { + const { idAttribute = 'id' } = config + const mapped = mapKeys(data, idAttribute) + + return Object.prototype.hasOwnProperty.call(mapped, 'undefined') + ? undefined + : mapped +} diff --git a/services/web/frontend/stories/settings.stories.js b/services/web/frontend/stories/settings.stories.js new file mode 100644 index 0000000000..23e6117a34 --- /dev/null +++ b/services/web/frontend/stories/settings.stories.js @@ -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 +} + +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 +} + +export default { + title: 'Emails and Affiliations', + component: EmailsSection, +} diff --git a/services/web/frontend/stylesheets/_style_includes.less b/services/web/frontend/stylesheets/_style_includes.less index 3e1241accb..4a25b163e9 100644 --- a/services/web/frontend/stylesheets/_style_includes.less +++ b/services/web/frontend/stylesheets/_style_includes.less @@ -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'; diff --git a/services/web/frontend/stylesheets/app/account-settings.less b/services/web/frontend/stylesheets/app/account-settings.less index 436f0e8ee3..b8b53d4567 100644 --- a/services/web/frontend/stylesheets/app/account-settings.less +++ b/services/web/frontend/stylesheets/app/account-settings.less @@ -23,6 +23,9 @@ .affiliations-table { table-layout: fixed; } +.affiliations-table-cell { + padding: 0.5rem; +} .affiliations-table-email { width: 40%; } diff --git a/services/web/frontend/stylesheets/components/divider.less b/services/web/frontend/stylesheets/components/divider.less new file mode 100644 index 0000000000..d005f06e4a --- /dev/null +++ b/services/web/frontend/stylesheets/components/divider.less @@ -0,0 +1,3 @@ +.horizontal-divider { + border-top: 1px solid @table-border-color; +} diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx new file mode 100644 index 0000000000..c62455ddb5 --- /dev/null +++ b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx @@ -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('', function () { + afterEach(function () { + window.metaAttributesCache = new Map() + fetchMock.reset() + }) + + it('renders translated heading', function () { + window.metaAttributesCache.set('ol-userEmails', fakeUsersData) + render() + + screen.getByRole('heading', { name: /emails and affiliations/i }) + }) + + it('renders translated description', function () { + window.metaAttributesCache.set('ol-userEmails', fakeUsersData) + render() + + 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() + + fakeUsersData.forEach(userData => { + screen.getByText(new RegExp(userData.email, 'i')) + }) + }) + + it('renders primary status', function () { + window.metaAttributesCache.set('ol-userEmails', fakeUsersData) + render() + + 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() + + screen.getByText(/please check your inbox/i) + }) + + it('hides confirmation status for confirmed users', function () { + window.metaAttributesCache.set('ol-userEmails', [confirmedUserData]) + render() + + expect(screen.queryByText(/please check your inbox/i)).to.be.null + }) + + it('renders resend link', function () { + window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData]) + render() + + screen.getByRole('button', { name: /resend confirmation email/i }) + }) + + it('renders professional label', function () { + window.metaAttributesCache.set('ol-userEmails', [professionalUserData]) + render() + + 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() + + 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() + + 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 }) + }) +}) diff --git a/services/web/test/frontend/shared/hooks/use-async.test.ts b/services/web/test/frontend/shared/hooks/use-async.test.ts new file mode 100644 index 0000000000..c812df26b1 --- /dev/null +++ b/services/web/test/frontend/shared/hooks/use-async.test.ts @@ -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 | PromiseLike> + ) => void + let rej!: (reason?: any) => void + + const promise = new Promise>((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 + 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 + 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 + act(() => { + p = result.current.runAsync(promise) + }) + unmount() + await act(async () => { + resolve({}) + await p + }) + + expect(global.console.error).not.to.have.been.called + }) +}) diff --git a/services/web/types/affiliation.ts b/services/web/types/affiliation.ts new file mode 100644 index 0000000000..5b76d77494 --- /dev/null +++ b/services/web/types/affiliation.ts @@ -0,0 +1,6 @@ +import { Institution } from './institution' + +export type Affiliation = { + institution: Institution + licence?: 'free' | 'pro_plus' +} diff --git a/services/web/types/institution.ts b/services/web/types/institution.ts new file mode 100644 index 0000000000..f857032b17 --- /dev/null +++ b/services/web/types/institution.ts @@ -0,0 +1 @@ +export type Institution = Record diff --git a/services/web/types/user-email.ts b/services/web/types/user-email.ts new file mode 100644 index 0000000000..b6ae8b8d42 --- /dev/null +++ b/services/web/types/user-email.ts @@ -0,0 +1,9 @@ +import { Affiliation } from './affiliation' + +export type UserEmailData = { + affiliation?: Affiliation + confirmedAt: string + email: string + default: boolean + ssoAvailable?: boolean +} diff --git a/services/web/types/utils.ts b/services/web/types/utils.ts new file mode 100644 index 0000000000..36fa025789 --- /dev/null +++ b/services/web/types/utils.ts @@ -0,0 +1 @@ +export type Nullable = T | null