[Settings] React SSO Linking Widget (#7395)

GitOrigin-RevId: 103e8382daf2f38a302a92cb4fb11e4f4a520df2
This commit is contained in:
Miguel Serrano 2022-04-08 13:03:41 +02:00 committed by Copybot
parent ae633b1b44
commit 088b8c017e
2 changed files with 276 additions and 0 deletions

View file

@ -0,0 +1,151 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Modal } from 'react-bootstrap'
import AccessibleModal from '../../../shared/components/accessible-modal'
type SSOLinkingWidgetProps = {
logoSrc: string
title: string
description: string
linked?: boolean
linkPath: string
onUnlink: () => Promise<void>
unlinkConfirmationTitle: string
unlinkConfirmationText: string
}
export function SSOLinkingWidget({
logoSrc,
title,
description,
linked,
linkPath,
onUnlink,
unlinkConfirmationTitle,
unlinkConfirmationText,
}: SSOLinkingWidgetProps) {
const [showModal, setShowModal] = useState(false)
const [unlinkRequestInflight, setUnlinkRequestInflight] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const handleUnlinkClick = useCallback(() => {
setShowModal(true)
}, [])
const handleUnlinkConfirmationClick = useCallback(() => {
setShowModal(false)
setUnlinkRequestInflight(true)
onUnlink()
.catch((error: Error) => {
setErrorMessage(error.message)
})
.finally(() => {
setUnlinkRequestInflight(false)
})
}, [onUnlink])
const handleModalHide = useCallback(() => {
setShowModal(false)
}, [])
return (
<div className="row">
<div className="col-xs-2 col-sm-2 col-md-2">
<img alt={title} src={logoSrc} />
</div>
<div className="col-xs-10 col-sm-6 col-md-8">
<h4>{title}</h4>
<p>{description}</p>
{errorMessage && <div>{errorMessage} </div>}
</div>
<div className="col-xs-2 col-sm-4 col-md-2 text-right">
<ActionButton
unlinkRequestInflight={unlinkRequestInflight}
accountIsLinked={linked}
linkPath={linkPath}
onUnlinkClick={handleUnlinkClick}
/>
</div>
<UnlinkConfirmModal
show={showModal}
title={unlinkConfirmationTitle}
content={unlinkConfirmationText}
handleConfirmation={handleUnlinkConfirmationClick}
handleHide={handleModalHide}
/>
</div>
)
}
type ActionButtonProps = {
unlinkRequestInflight: boolean
accountIsLinked?: boolean
linkPath: string
onUnlinkClick: () => void
}
function ActionButton({
unlinkRequestInflight,
accountIsLinked,
linkPath,
onUnlinkClick,
}: ActionButtonProps) {
const { t } = useTranslation()
if (unlinkRequestInflight) {
return (
<button disabled className="btn default">
{t('unlinking')}
</button>
)
} else if (accountIsLinked) {
return (
<button className="btn default" onClick={onUnlinkClick}>
{t('unlink')}
</button>
)
} else {
return (
<a href={linkPath} className="btn btn-primary text-capitalize">
{t('link')}
</a>
)
}
}
type UnlinkConfirmModalProps = {
show: boolean
title: string
content: string
handleConfirmation: () => void
handleHide: () => void
}
function UnlinkConfirmModal({
show,
title,
content,
handleConfirmation,
handleHide,
}: UnlinkConfirmModalProps) {
const { t } = useTranslation()
return (
<AccessibleModal show={show} onHide={handleHide}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body className="modal-body-share">
<p>{content}</p>
</Modal.Body>
<Modal.Footer>
<Button bsStyle="default" onClick={handleHide}>
{t('cancel')}
</Button>
<Button bsStyle="danger" onClick={handleConfirmation}>
{t('unlink')}
</Button>
</Modal.Footer>
</AccessibleModal>
)
}

View file

@ -0,0 +1,125 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { screen, fireEvent, render, waitFor } from '@testing-library/react'
import { SSOLinkingWidget } from '../../../../../frontend/js/features/user-settings/components/sso-linking-widget'
describe('<SSOLinkingWidget />', function () {
const defaultProps = {
logoSrc: 'logo.png',
title: 'integration',
description: 'integration description',
linkPath: 'integration/link',
unlinkConfirmationTitle: 'confirm unlink',
unlinkConfirmationText: 'you will be unlinked',
onUnlink: () => Promise.resolve(),
}
it('should render', function () {
render(<SSOLinkingWidget {...defaultProps} />)
screen.getByText('integration')
screen.getByText('integration description')
})
describe('when unlinked', function () {
it('should render a link to `linkPath`', function () {
render(<SSOLinkingWidget {...defaultProps} linked={false} />)
expect(
screen.getByRole('link', { name: 'link' }).getAttribute('href')
).to.equal('integration/link')
})
})
describe('when linked', function () {
let unlinkFunction
beforeEach(function () {
unlinkFunction = sinon.stub()
render(
<SSOLinkingWidget {...defaultProps} linked onUnlink={unlinkFunction} />
)
})
it('should display an `unlink` button', function () {
screen.getByRole('button', { name: 'Unlink' })
})
it('should open a modal to confirm integration unlinking', function () {
fireEvent.click(screen.getByRole('button', { name: 'Unlink' }))
screen.getByText('confirm unlink')
screen.getByText('you will be unlinked')
})
it('should cancel unlinking when clicking cancel in the confirmation modal', async function () {
fireEvent.click(screen.getByRole('button', { name: 'Unlink' }))
screen.getByText('confirm unlink')
const cancelBtn = screen.getByRole('button', {
name: 'Cancel',
hidden: false,
})
fireEvent.click(cancelBtn)
await waitFor(() =>
screen.getByRole('button', { name: 'Cancel', hidden: true })
)
expect(unlinkFunction).not.to.have.been.called
})
})
describe('unlinking an account', function () {
let confirmBtn, unlinkFunction
beforeEach(function () {
unlinkFunction = sinon.stub()
render(
<SSOLinkingWidget {...defaultProps} linked onUnlink={unlinkFunction} />
)
fireEvent.click(screen.getByRole('button', { name: 'Unlink' }))
confirmBtn = screen.getByRole('button', {
name: 'Unlink',
hidden: false,
})
})
it('should make an `unlink` request', function () {
unlinkFunction.resolves()
fireEvent.click(confirmBtn)
expect(unlinkFunction).to.have.been.called
})
it('should display feedback while the request is inflight', async function () {
unlinkFunction.returns(
new Promise<void>(resolve => {
setTimeout(resolve, 500)
})
)
fireEvent.click(confirmBtn)
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Unlinking' }))
)
})
})
describe('when unlinking fails', function () {
beforeEach(function () {
const unlinkFunction = sinon.stub().rejects(new Error('unlinking failed'))
render(
<SSOLinkingWidget {...defaultProps} linked onUnlink={unlinkFunction} />
)
fireEvent.click(screen.getByRole('button', { name: 'Unlink' }))
const confirmBtn = screen.getByRole('button', {
name: 'Unlink',
hidden: false,
})
fireEvent.click(confirmBtn)
})
it('should display an error message ', async function () {
await waitFor(() => screen.getByText('unlinking failed'))
})
it('should display the unlink button ', async function () {
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Unlink' }))
)
})
})
})