From a3c54c73696e2baa080141068ebb1e5e9bd0f997 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe <5312836+lawshe@users.noreply.github.com> Date: Wed, 13 Sep 2023 07:31:56 -0500 Subject: [PATCH] Merge pull request #14627 from overleaf/jel-new-alerts [web] New notification styles GitOrigin-RevId: ad8a102bbe1ab24be3fccc061f5bbf54912c77e4 --- .../js/shared/components/notification.tsx | 104 ++++++ .../frontend/stories/notification.stories.tsx | 317 ++++++++++++++++++ .../frontend/stories/style-guide.stories.js | 51 ++- .../stylesheets/app/project-list.less | 26 -- .../stylesheets/components/alerts.less | 18 - .../stylesheets/components/notifications.less | 183 ++++++++++ .../frontend/stylesheets/core/variables.less | 8 + .../shared/components/notification.test.tsx | 54 +++ 8 files changed, 715 insertions(+), 46 deletions(-) create mode 100644 services/web/frontend/js/shared/components/notification.tsx create mode 100644 services/web/frontend/stories/notification.stories.tsx create mode 100644 services/web/test/frontend/shared/components/notification.test.tsx diff --git a/services/web/frontend/js/shared/components/notification.tsx b/services/web/frontend/js/shared/components/notification.tsx new file mode 100644 index 0000000000..aa36ff8940 --- /dev/null +++ b/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 = + + if (customIcon) { + icon = customIcon + } else if (notificationType === 'success') { + icon = + } else if (notificationType === 'warning') { + icon = + } else if (notificationType === 'error') { + icon = + } + + return
{icon}
+} + +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 ( +
+ + +
+
+ {title && ( +

+ {title} +

+ )} + {content} +
+ {action &&
{action}
} +
+ + {isDismissible && ( +
+ +
+ )} +
+ ) +} + +export default Notification diff --git a/services/web/frontend/stories/notification.stories.tsx b/services/web/frontend/stories/notification.stories.tsx new file mode 100644 index 0000000000..e761da39c5 --- /dev/null +++ b/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 + +export const NotificationInfo = (args: Args) => { + return +} + +export const NotificationSuccess = (args: Args) => { + return +} + +export const NotificationWarning = (args: Args) => { + return +} + +export const NotificationError = (args: Args) => { + return +} + +export const NotificationWithActionBelowContent = (args: Args) => { + return ( + +

The CTA will always go below the content on small screens.

+

+ We can also opt to always put the CTA below the content on all + screens +

+ + } + isDismissible + isActionBelowContent + /> + ) +} + +export const NotificationWithTitle = (args: Args) => { + return +} + +export const NotificationWithAction = (args: Args) => { + return +} + +export const NotificationDismissible = (args: Args) => { + return +} + +export const APlainNotification = (args: Args) => { + return +} + +export const NotificationWithMultipleParagraphsAndActionAndDismissible = ( + args: Args +) => { + return ( + +

+ Lorem ipsum +

+

+ Dolor sit amet, consectetur adipiscing elit. Proin lacus velit, + faucibus vitae feugiat sit amet, Some link iaculis + ut mi. +

+

+ Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum + iaculis eu non diam phasellus. +

+

Aliquam at tempor risus. Vestibulum bibendum ut

+ + } + /> + ) +} + +export const NotificationWithMultipleParagraphsAndDismissible = ( + args: Args +) => { + return ( + +

+ Lorem ipsum +

+

+ Dolor sit amet, consectetur adipiscing elit. Proin lacus velit, + faucibus vitae feugiat sit amet, Some link iaculis + ut mi. +

+

+ Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum + iaculis eu non diam phasellus. +

+

Aliquam at tempor risus. Vestibulum bibendum ut

+ + } + /> + ) +} + +export const MultipleParagraphsAndAction = (args: Args) => { + return ( + +

+ Lorem ipsum +

+

+ Dolor sit amet, consectetur adipiscing elit. Proin lacus velit, + faucibus vitae feugiat sit amet, Some link iaculis + ut mi. +

+

+ Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum + iaculis eu non diam phasellus. +

+

Aliquam at tempor risus. Vestibulum bibendum ut

+ + } + /> + ) +} + +export const MultipleParagraphs = (args: Args) => { + return ( + +

+ Lorem ipsum +

+

+ Dolor sit amet, consectetur adipiscing elit. Proin lacus velit, + faucibus vitae feugiat sit amet, Some link iaculis + ut mi. +

+

+ Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum + iaculis eu non diam phasellus. +

+

Aliquam at tempor risus. Vestibulum bibendum ut

+ + } + /> + ) +} + +export const ShortText = (args: Args) => { + return ( + Lorem ipsum

} + /> + ) +} + +export const ShortTextAndDismissible = (args: Args) => { + return ( + Lorem ipsum

} /> + ) +} + +export const ShortTextAndActionLinkAsButton = (args: Args) => { + return ( + Lorem ipsum

} + /> + ) +} + +export const ShortTextAndActionAsLink = (args: Args) => { + return ( + Lorem ipsum

} + action={An action} + isDismissible={false} + /> + ) +} +export const ShortTextAndActionAsLinkButStyledAsButton = (args: Args) => { + return ( + Lorem ipsum

} + action={ + + An action + + } + isDismissible={false} + /> + ) +} + +export const LongActionButton = (args: Args) => { + return ( + + Action that has a lot of text + + } + /> + ) +} + +export const LongActionLink = (args: Args) => { + return ( + Action that has a lot of text} + /> + ) +} + +export const CustomIcon = (args: Args) => { + return ( + 🎉} + /> + ) +} + +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 = ( + + ) + + const startNotification = ( + + 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. +

+ } + /> + ) + const successNotification = ( + Now follow this link to go home} + type="success" + content={

Success! You made a successful request.

} + /> + ) + + if (isSuccess) return successNotification + return startNotification +} + +export default { + title: 'Shared / Components / Notification', + component: Notification, + args: { + content: ( +

+ This can be any HTML passed to the component. For example, + paragraphs, headers, code samples, links, + etc are all supported. +

+ ), + action: , + isDismissible: true, + }, +} diff --git a/services/web/frontend/stories/style-guide.stories.js b/services/web/frontend/stories/style-guide.stories.js index ffac424a98..5276164176 100644 --- a/services/web/frontend/stories/style-guide.stories.js +++ b/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 = () => {
- -

Alerts

+ +

Alerts / Notifications

+

See Notification in shared components for options

+ + + .notitifcation .notification-type-info + +
+ } + /> + + + .notitifcation .notification-type-success + + + } + /> + + + .notitifcation .notification-type-warning + + + } + /> + + + + .notitifcation .notification-type-error + + + } + /> + + Note: these styles below will be deprecated since there are new + alert styles rolling out as part of the new design system + An .alert-danger alert diff --git a/services/web/frontend/stylesheets/app/project-list.less b/services/web/frontend/stylesheets/app/project-list.less index 8e6ec67bfb..1a461e59b5 100644 --- a/services/web/frontend/stylesheets/app/project-list.less +++ b/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; diff --git a/services/web/frontend/stylesheets/components/alerts.less b/services/web/frontend/stylesheets/components/alerts.less index 8e6c045e3f..e84a12d6e4 100755 --- a/services/web/frontend/stylesheets/components/alerts.less +++ b/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; - } -} diff --git a/services/web/frontend/stylesheets/components/notifications.less b/services/web/frontend/stylesheets/components/notifications.less index 0c90b82563..18ac669655 100644 --- a/services/web/frontend/stylesheets/components/notifications.less +++ b/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%; diff --git a/services/web/frontend/stylesheets/core/variables.less b/services/web/frontend/stylesheets/core/variables.less index 9fc342595d..75db0a2bcd 100644 --- a/services/web/frontend/stylesheets/core/variables.less +++ b/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; diff --git a/services/web/test/frontend/shared/components/notification.test.tsx b/services/web/test/frontend/shared/components/notification.test.tsx new file mode 100644 index 0000000000..d7f8077271 --- /dev/null +++ b/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('', 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(A notification

} />) + screen.getByText('A notification') + expect(screen.queryByRole('button', { name: 'Close' })).to.be.null + }) + + it('renders with action', function () { + render( + A notification

} + action={Action} + /> + ) + screen.getByText('A notification') + screen.getByRole('link', { name: 'Action' }) + }) + + it('renders with close button', function () { + render( + A notification

} isDismissible /> + ) + screen.getByText('A notification') + screen.getByRole('button', { name: 'Close' }) + }) + + it('renders with title', function () { + render( + A notification

} + title="A title" + /> + ) + screen.getByText('A title') + }) +})