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

Merge pull request #14627 from overleaf/jel-new-alerts

[web] New notification styles

GitOrigin-RevId: ad8a102bbe1ab24be3fccc061f5bbf54912c77e4
Jessica Lawshe 2 лет назад
Родитель
Сommit
a3c54c7369

+ 104 - 0
services/web/frontend/js/shared/components/notification.tsx

@@ -0,0 +1,104 @@
+import classNames from 'classnames'
+import React, { ReactElement, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import MaterialIcon from './material-icon'
+
+type NotificationType = 'info' | 'success' | 'warning' | 'error'
+
+type NotificationProps = {
+  action?: React.ReactElement
+  ariaLive?: 'polite' | 'off' | 'assertive'
+  content: React.ReactElement
+  customIcon?: React.ReactElement
+  isDismissible?: boolean
+  isActionBelowContent?: boolean
+  onDismiss?: () => void
+  title?: string
+  type: NotificationType
+}
+
+function NotificationIcon({
+  notificationType,
+  customIcon,
+}: {
+  notificationType: NotificationType
+  customIcon?: ReactElement
+}) {
+  let icon = <MaterialIcon type="info" />
+
+  if (customIcon) {
+    icon = customIcon
+  } else if (notificationType === 'success') {
+    icon = <MaterialIcon type="check_circle" />
+  } else if (notificationType === 'warning') {
+    icon = <MaterialIcon type="warning" />
+  } else if (notificationType === 'error') {
+    icon = <MaterialIcon type="error" />
+  }
+
+  return <div className="notification-icon">{icon}</div>
+}
+
+function Notification({
+  action,
+  ariaLive,
+  content,
+  customIcon,
+  isActionBelowContent,
+  isDismissible,
+  onDismiss,
+  title,
+  type,
+}: NotificationProps) {
+  type = type || 'info'
+  const { t } = useTranslation()
+  const [show, setShow] = useState(true)
+
+  const notificationClassName = classNames(
+    'notification',
+    `notification-type-${type}`,
+    isDismissible ? 'notification-dismissible' : '',
+    isActionBelowContent ? 'notification-cta-below-content' : ''
+  )
+
+  const handleDismiss = () => {
+    setShow(false)
+    if (onDismiss) onDismiss()
+  }
+
+  if (!show) {
+    return null
+  }
+
+  return (
+    <div
+      className={notificationClassName}
+      aria-live={ariaLive || 'off'}
+      role="alert"
+    >
+      <NotificationIcon notificationType={type} customIcon={customIcon} />
+
+      <div className="notification-content-and-cta">
+        <div className="notification-content">
+          {title && (
+            <p>
+              <b>{title}</b>
+            </p>
+          )}
+          {content}
+        </div>
+        {action && <div className="notification-cta">{action}</div>}
+      </div>
+
+      {isDismissible && (
+        <div className="notification-close-btn">
+          <button aria-label={t('close')} onClick={handleDismiss}>
+            <MaterialIcon type="close" />
+          </button>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default Notification

+ 317 - 0
services/web/frontend/stories/notification.stories.tsx

@@ -0,0 +1,317 @@
+import fetchMock from 'fetch-mock'
+import Notification from '../js/shared/components/notification'
+import { postJSON } from '../js/infrastructure/fetch-json'
+import useAsync from '../js/shared/hooks/use-async'
+
+type Args = React.ComponentProps<typeof Notification>
+
+export const NotificationInfo = (args: Args) => {
+  return <Notification {...args} isDismissible />
+}
+
+export const NotificationSuccess = (args: Args) => {
+  return <Notification {...args} isDismissible type="success" />
+}
+
+export const NotificationWarning = (args: Args) => {
+  return <Notification {...args} isDismissible type="warning" />
+}
+
+export const NotificationError = (args: Args) => {
+  return <Notification {...args} isDismissible type="error" />
+}
+
+export const NotificationWithActionBelowContent = (args: Args) => {
+  return (
+    <Notification
+      {...args}
+      content={
+        <div>
+          <p>The CTA will always go below the content on small screens.</p>
+          <p>
+            We can also opt to always put the CTA below the content on all
+            screens
+          </p>
+        </div>
+      }
+      isDismissible
+      isActionBelowContent
+    />
+  )
+}
+
+export const NotificationWithTitle = (args: Args) => {
+  return <Notification {...args} title="Some title" />
+}
+
+export const NotificationWithAction = (args: Args) => {
+  return <Notification {...args} isDismissible={false} />
+}
+
+export const NotificationDismissible = (args: Args) => {
+  return <Notification {...args} action={undefined} />
+}
+
+export const APlainNotification = (args: Args) => {
+  return <Notification {...args} action={undefined} isDismissible={false} />
+}
+
+export const NotificationWithMultipleParagraphsAndActionAndDismissible = (
+  args: Args
+) => {
+  return (
+    <Notification
+      {...args}
+      content={
+        <div>
+          <p>
+            <b>Lorem ipsum</b>
+          </p>
+          <p>
+            Dolor sit amet, consectetur adipiscing elit. Proin lacus velit,
+            faucibus vitae feugiat sit amet, <a href="/">Some link</a> iaculis
+            ut mi.
+          </p>
+          <p>
+            Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum
+            iaculis eu non diam phasellus.
+          </p>
+          <p>Aliquam at tempor risus. Vestibulum bibendum ut </p>
+        </div>
+      }
+    />
+  )
+}
+
+export const NotificationWithMultipleParagraphsAndDismissible = (
+  args: Args
+) => {
+  return (
+    <Notification
+      {...args}
+      action={undefined}
+      content={
+        <div>
+          <p>
+            <b>Lorem ipsum</b>
+          </p>
+          <p>
+            Dolor sit amet, consectetur adipiscing elit. Proin lacus velit,
+            faucibus vitae feugiat sit amet, <a href="/">Some link</a> iaculis
+            ut mi.
+          </p>
+          <p>
+            Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum
+            iaculis eu non diam phasellus.
+          </p>
+          <p>Aliquam at tempor risus. Vestibulum bibendum ut </p>
+        </div>
+      }
+    />
+  )
+}
+
+export const MultipleParagraphsAndAction = (args: Args) => {
+  return (
+    <Notification
+      {...args}
+      isDismissible={false}
+      content={
+        <div>
+          <p>
+            <b>Lorem ipsum</b>
+          </p>
+          <p>
+            Dolor sit amet, consectetur adipiscing elit. Proin lacus velit,
+            faucibus vitae feugiat sit amet, <a href="/">Some link</a> iaculis
+            ut mi.
+          </p>
+          <p>
+            Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum
+            iaculis eu non diam phasellus.
+          </p>
+          <p>Aliquam at tempor risus. Vestibulum bibendum ut </p>
+        </div>
+      }
+    />
+  )
+}
+
+export const MultipleParagraphs = (args: Args) => {
+  return (
+    <Notification
+      {...args}
+      action={undefined}
+      isDismissible={false}
+      content={
+        <div>
+          <p>
+            <b>Lorem ipsum</b>
+          </p>
+          <p>
+            Dolor sit amet, consectetur adipiscing elit. Proin lacus velit,
+            faucibus vitae feugiat sit amet, <a href="/">Some link</a> iaculis
+            ut mi.
+          </p>
+          <p>
+            Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum
+            iaculis eu non diam phasellus.
+          </p>
+          <p>Aliquam at tempor risus. Vestibulum bibendum ut </p>
+        </div>
+      }
+    />
+  )
+}
+
+export const ShortText = (args: Args) => {
+  return (
+    <Notification
+      {...args}
+      action={undefined}
+      isDismissible={false}
+      content={<p>Lorem ipsum</p>}
+    />
+  )
+}
+
+export const ShortTextAndDismissible = (args: Args) => {
+  return (
+    <Notification {...args} action={undefined} content={<p>Lorem ipsum</p>} />
+  )
+}
+
+export const ShortTextAndActionLinkAsButton = (args: Args) => {
+  return (
+    <Notification
+      {...args}
+      isDismissible={false}
+      content={<p>Lorem ipsum</p>}
+    />
+  )
+}
+
+export const ShortTextAndActionAsLink = (args: Args) => {
+  return (
+    <Notification
+      {...args}
+      content={<p>Lorem ipsum</p>}
+      action={<a href="/">An action</a>}
+      isDismissible={false}
+    />
+  )
+}
+export const ShortTextAndActionAsLinkButStyledAsButton = (args: Args) => {
+  return (
+    <Notification
+      {...args}
+      content={<p>Lorem ipsum</p>}
+      action={
+        <a href="/" className="btn btn-secondary btn-sm">
+          An action
+        </a>
+      }
+      isDismissible={false}
+    />
+  )
+}
+
+export const LongActionButton = (args: Args) => {
+  return (
+    <Notification
+      {...args}
+      action={
+        <button className="btn btn-secondary btn-sm">
+          Action that has a lot of text
+        </button>
+      }
+    />
+  )
+}
+
+export const LongActionLink = (args: Args) => {
+  return (
+    <Notification
+      {...args}
+      action={<a href="/">Action that has a lot of text</a>}
+    />
+  )
+}
+
+export const CustomIcon = (args: Args) => {
+  return (
+    <Notification
+      {...args}
+      customIcon={<div style={{ marginTop: '-4px' }}>🎉</div>}
+    />
+  )
+}
+
+export const SuccessFlow = (args: Args) => {
+  console.log('.....render')
+  fetchMock.post(
+    'express:/test-success',
+    { status: 200 },
+    { delay: 250, overwriteRoutes: true }
+  )
+
+  const { isLoading, isSuccess, runAsync } = useAsync()
+  function handleClick() {
+    console.log('clicked')
+    runAsync(postJSON('/test-success')).catch(console.error)
+  }
+
+  const ctaText = isLoading ? 'Processing' : 'Click'
+  const action = (
+    <button
+      className="btn btn-secondary btn-sm"
+      onClick={() => handleClick()}
+      disabled={isLoading}
+    >
+      {ctaText}
+    </button>
+  )
+
+  const startNotification = (
+    <Notification
+      {...args}
+      action={action}
+      title="An example notification flow"
+      content={
+        <p>
+          This story shows 2 notifications, and it's up to the parent component
+          to determine which to show. There's a successful request made after
+          clicking the action and so the parent component then renders the
+          success notification.
+        </p>
+      }
+    />
+  )
+  const successNotification = (
+    <Notification
+      {...args}
+      action={<a href="/">Now follow this link to go home</a>}
+      type="success"
+      content={<p>Success! You made a successful request.</p>}
+    />
+  )
+
+  if (isSuccess) return successNotification
+  return startNotification
+}
+
+export default {
+  title: 'Shared / Components / Notification',
+  component: Notification,
+  args: {
+    content: (
+      <p>
+        This can be <b>any HTML</b> passed to the component. For example,
+        paragraphs, headers, <code>code samples</code>, <a href="/">links</a>,
+        etc are all supported.
+      </p>
+    ),
+    action: <button className="btn btn-secondary btn-sm">An action</button>,
+    isDismissible: true,
+  },
+}

+ 49 - 2
services/web/frontend/stories/style-guide.stories.js

@@ -1,6 +1,7 @@
 /* eslint-disable jsx-a11y/anchor-is-valid */
 
 import { Grid, Row, Col, Button, Alert, ProgressBar } from 'react-bootstrap'
+import Notification from '../js/shared/components/notification'
 
 export const Colors = () => {
   return (
@@ -242,8 +243,54 @@ export const Alerts = () => {
     <div className="content content-alt">
       <Grid>
         <Row>
-          <Col md={8} mdOffset={2}>
-            <h2>Alerts</h2>
+          <Col md={8} mdOffset={2} className="notification-list">
+            <h2>Alerts / Notifications</h2>
+            <p>See Notification in shared components for options</p>
+            <Notification
+              type="info"
+              body={
+                <div>
+                  <b>
+                    <code>.notitifcation .notification-type-info</code>
+                  </b>
+                </div>
+              }
+            />
+            <Notification
+              type="success"
+              body={
+                <div>
+                  <b>
+                    <code>.notitifcation .notification-type-success</code>
+                  </b>
+                </div>
+              }
+            />
+            <Notification
+              type="warning"
+              body={
+                <div>
+                  <b>
+                    <code>.notitifcation .notification-type-warning</code>
+                  </b>
+                </div>
+              }
+            />
+
+            <Notification
+              type="error"
+              body={
+                <div>
+                  <b>
+                    <code>.notitifcation .notification-type-error</code>
+                  </b>
+                </div>
+              }
+            />
+            <b>
+              Note: these styles below will be deprecated since there are new
+              alert styles rolling out as part of the new design system
+            </b>
 
             <Alert bsStyle="danger">
               An <code>.alert-danger</code> alert

+ 0 - 26
services/web/frontend/stylesheets/app/project-list.less

@@ -253,32 +253,6 @@ input.project-list-table-select-item[type='checkbox'] {
     }
   }
 }
-.notification-body {
-  flex-grow: 1;
-  width: 90%;
-  @media (min-width: @screen-sm-min) {
-    width: auto;
-  }
-}
-
-.notification-action {
-  margin-top: (@line-height-computed / 2); // match paragraph padding
-  order: 1;
-  @media (min-width: @screen-sm-min) {
-    margin-top: 0;
-    order: 0;
-    padding-left: @padding-sm;
-  }
-}
-
-.notification-close {
-  padding-left: @padding-sm;
-  text-align: right;
-  width: 10%;
-  @media (min-width: @screen-sm-min) {
-    width: auto;
-  }
-}
 
 ul.folders-menu {
   margin: @folders-menu-margin;

+ 0 - 18
services/web/frontend/stylesheets/components/alerts.less

@@ -116,21 +116,3 @@
     text-decoration: none;
   }
 }
-
-.design-system {
-  .alert {
-    display: flex;
-    flex-direction: row;
-
-    .icon {
-      flex: 1 1 auto;
-      padding: 0 16px 0 4px;
-    }
-  }
-  .alert-info {
-    background-color: @blue-10;
-    border: 1px solid @blue-20;
-    border-radius: @border-radius-base-new;
-    color: @content-primary;
-  }
-}

+ 183 - 0
services/web/frontend/stylesheets/components/notifications.less

@@ -1,3 +1,186 @@
+.notification-body {
+  // will be deprecated once notifications moved to use .notification (see below)
+  flex-grow: 1;
+  width: 90%;
+  @media (min-width: @screen-sm-min) {
+    width: auto;
+  }
+}
+
+.notification-action {
+  // will be deprecated once notifications moved to use .notification (see below)
+  margin-top: (@line-height-computed / 2); // match paragraph padding
+  order: 1;
+  @media (min-width: @screen-sm-min) {
+    margin-top: 0;
+    order: 0;
+    padding-left: @padding-sm;
+  }
+}
+
+.notification-close {
+  // will be deprecated once notifications moved to use .notification (see below)
+  padding-left: @padding-sm;
+  text-align: right;
+  width: 10%;
+
+  button {
+    aspect-ratio: 1;
+    border-radius: 50%;
+    display: flex;
+    float: right;
+    padding: 5.5px;
+    cursor: pointer;
+    background: transparent;
+    border: 0;
+
+    &:hover,
+    &:focus {
+      background-color: rgba(@neutral-90, 0.08);
+      color: @text-color;
+    }
+  }
+
+  @media (min-width: @screen-sm-min) {
+    width: auto;
+  }
+}
+
+.notification {
+  border-radius: @border-radius-base-new;
+  color: @content-primary;
+  display: flex;
+  padding: 0 16px 0 16px; // vertical padding added by elements within notification
+  width: 100%;
+
+  a:not(.btn) {
+    text-decoration: underline;
+  }
+
+  p {
+    margin-bottom: 4px;
+  }
+
+  .notification-icon {
+    flex-grow: 0;
+    padding: 18px 16px 0 0;
+  }
+
+  .notification-content-and-cta {
+    // shared container to align cta with text on smaller screens
+    display: flex;
+    flex-grow: 1;
+    flex-wrap: wrap;
+
+    p:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .notification-content {
+    flex-grow: 1;
+    padding: 16px 0 16px 0;
+    width: 100%;
+  }
+
+  .notification-cta {
+    padding-bottom: 16px;
+
+    a {
+      font-weight: 700;
+    }
+
+    a,
+    button {
+      white-space: nowrap;
+    }
+  }
+
+  .notification-close-btn {
+    height: 56px;
+    align-items: center;
+    display: flex;
+  }
+
+  .notification-close-btn {
+    padding: 0 0 0 16px;
+
+    button {
+      aspect-ratio: 1;
+      border-radius: 50%;
+      display: flex;
+      float: right;
+      padding: 5.5px;
+      cursor: pointer;
+      background: transparent;
+      border: 0;
+
+      &:hover,
+      &:focus {
+        background-color: rgba(@neutral-90, 0.08);
+        color: @text-color;
+      }
+    }
+  }
+
+  &.notification-type-info {
+    background-color: @blue-10;
+    border: 1px solid @blue-20;
+    .notification-icon {
+      color: @blue-50;
+    }
+  }
+  &.notification-type-success {
+    background-color: @green-10;
+    border: 1px solid @green-20;
+    .notification-icon {
+      color: @green-50;
+    }
+  }
+  &.notification-type-warning {
+    background-color: @yellow-10;
+    border: 1px solid @yellow-20;
+    .notification-icon {
+      color: @yellow-40;
+    }
+  }
+  &.notification-type-error {
+    background-color: @red-10;
+    border: 1px solid @red-20;
+    .notification-icon {
+      color: @red-50;
+    }
+  }
+
+  @media (min-width: @screen-sm-min) {
+    &:not(.notification-cta-below-content) {
+      .notification-content-and-cta {
+        flex-wrap: nowrap;
+      }
+
+      .notification-content {
+        width: auto;
+      }
+
+      .notification-cta {
+        height: 56px;
+        padding-left: 16px;
+        padding-bottom: 0;
+        align-items: center;
+        display: flex;
+      }
+    }
+  }
+}
+
+.notification-list {
+  .notification {
+    margin-bottom: @margin-md;
+  }
+}
+
+// Reconfirmation notification
+
 .reconfirm-notification {
   display: flex;
   width: 100%;

+ 8 - 0
services/web/frontend/stylesheets/core/variables.less

@@ -40,16 +40,24 @@
 @blue-20: #c3d0e3;
 @blue-30: #97b6e5;
 @blue-40: #6597e0;
+@blue-50: #3265b2;
 @blue-70: #214475;
 @blueDark: #040d2d;
 @green: #46a546;
 @green-10: #ebf6ea;
+@green-20: #bbdbb8;
 @green-50: #138a07;
 @green-70: #1f5919;
 @green-30: #8cca86;
 @red: #a93529;
+@red-10: #f9f1f1;
+@red-20: #f5beba;
+@red-50: #b83a33;
 @yellow: #a1a729;
 @yellow-10: #fcf1e3;
+@yellow-20: #fcc483;
+@yellow-30: #f7a445;
+@yellow-40: #de8014;
 @yellow-50: #8f5514;
 @orange: #f89406;
 @orange-dark: #9e5e04;

+ 54 - 0
services/web/test/frontend/shared/components/notification.test.tsx

@@ -0,0 +1,54 @@
+import { expect } from 'chai'
+import { screen, render } from '@testing-library/react'
+import Notification from '../../../../frontend/js/shared/components/notification'
+import * as eventTracking from '../../../../frontend/js/infrastructure/event-tracking'
+import sinon from 'sinon'
+
+describe('<Notification />', function () {
+  let sendMBSpy: sinon.SinonSpy
+
+  beforeEach(function () {
+    sendMBSpy = sinon.spy(eventTracking, 'sendMB')
+  })
+
+  afterEach(function () {
+    sendMBSpy.restore()
+  })
+
+  it('renders and is not dismissible by default', function () {
+    render(<Notification type="info" content={<p>A notification</p>} />)
+    screen.getByText('A notification')
+    expect(screen.queryByRole('button', { name: 'Close' })).to.be.null
+  })
+
+  it('renders with action', function () {
+    render(
+      <Notification
+        type="info"
+        content={<p>A notification</p>}
+        action={<a href="/">Action</a>}
+      />
+    )
+    screen.getByText('A notification')
+    screen.getByRole('link', { name: 'Action' })
+  })
+
+  it('renders with close button', function () {
+    render(
+      <Notification type="info" content={<p>A notification</p>} isDismissible />
+    )
+    screen.getByText('A notification')
+    screen.getByRole('button', { name: 'Close' })
+  })
+
+  it('renders with title', function () {
+    render(
+      <Notification
+        type="info"
+        content={<p>A notification</p>}
+        title="A title"
+      />
+    )
+    screen.getByText('A title')
+  })
+})