Просмотр исходного кода

[Settings] React SSO Linking Widget (#7395)

GitOrigin-RevId: 103e8382daf2f38a302a92cb4fb11e4f4a520df2
Miguel Serrano 4 лет назад
Родитель
Сommit
088b8c017e

+ 151 - 0
services/web/frontend/js/features/user-settings/components/sso-linking-widget.tsx

@@ -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>
+  )
+}

+ 125 - 0
services/web/test/frontend/features/user-settings/components/sso-linking-widget.test.tsx

@@ -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' }))
+      )
+    })
+  })
+})