0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-03 22:15:32 -05:00

feat(ui): add inline notification (#2432)

This commit is contained in:
simeng-li 2022-11-15 10:53:51 +08:00 committed by GitHub
parent 1be139dc46
commit ec2492700d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 176 additions and 142 deletions

View file

@ -83,6 +83,7 @@ const translation = {
link_phone_description: 'For added security, please link your phone with the account.', // UNTRANSLATED
link_email_or_phone_description:
'For added security, please link your email or phone with the account.', // UNTRANSLATED
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
},
error: {
username_password_mismatch: 'Benutzername oder Passwort ist falsch',

View file

@ -79,6 +79,7 @@ const translation = {
link_phone_description: 'For added security, please link your phone with the account.',
link_email_or_phone_description:
'For added security, please link your email or phone with the account.',
continue_with_more_information: 'For added security, please complete below account details.',
},
error: {
username_password_mismatch: 'Username and password do not match',

View file

@ -83,6 +83,7 @@ const translation = {
link_phone_description: 'For added security, please link your phone with the account.', // UNTRANSLATED
link_email_or_phone_description:
'For added security, please link your email or phone with the account.', // UNTRANSLATED
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
},
error: {
username_password_mismatch: "Le nom d'utilisateur et le mot de passe ne correspondent pas",

View file

@ -79,6 +79,7 @@ const translation = {
link_phone_description: 'For added security, please link your phone with the account.', // UNTRANSLATED
link_email_or_phone_description:
'For added security, please link your email or phone with the account.', // UNTRANSLATED
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
},
error: {
username_password_mismatch: '사용자 이름 또는 비밀번호가 일치하지 않아요.',

View file

@ -79,6 +79,7 @@ const translation = {
link_phone_description: 'For added security, please link your phone with the account.', // UNTRANSLATED
link_email_or_phone_description:
'For added security, please link your email or phone with the account.', // UNTRANSLATED
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
},
error: {
username_password_mismatch: 'O Utilizador e a password não correspondem',

View file

@ -80,6 +80,7 @@ const translation = {
link_phone_description: 'For added security, please link your phone with the account.', // UNTRANSLATED
link_email_or_phone_description:
'For added security, please link your email or phone with the account.', // UNTRANSLATED
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
},
error: {
username_password_mismatch: 'Kullanıcı adı ve şifre eşleşmiyor.',

View file

@ -75,6 +75,7 @@ const translation = {
link_email_description: '绑定邮箱以保障您的账号安全',
link_phone_description: '绑定手机号以保障您的账号安全',
link_email_or_phone_description: '绑定邮箱或手机号以保障您的账号安全',
continue_with_more_information: '为保障您的账号安全,需要您补充以下信息。',
},
error: {
username_password_mismatch: '用户名和密码不匹配',

View file

@ -0,0 +1,28 @@
@use '@/scss/underscore' as _;
.notification {
padding: _.unit(3) _.unit(4);
font: var(--font-body-2);
color: var(--color-type-primary);
background: var(--color-neutral-variant-90);
border: _.border(var(--color-neutral-variant-80));
box-shadow: var(--color-shadow-1);
border-radius: var(--radius);
@include _.flex_row;
.icon {
width: 20px;
height: 20px;
margin-right: _.unit(3);
color: var(--color-neutral-variant-60);
}
.message {
flex: 1;
margin-right: _.unit(4);
}
.link {
max-width: 20%;
}
}

View file

@ -0,0 +1,31 @@
import classNames from 'classnames';
import type { CSSProperties, ForwardedRef } from 'react';
import { forwardRef } from 'react';
import InfoIcon from '@/assets/icons/info-icon.svg';
import TextLink from '@/components/TextLink';
import * as styles from './index.module.scss';
/* eslint-disable react/require-default-props */
type Props = {
className?: string;
message: string;
onClose: () => void;
style?: CSSProperties;
};
/* eslint-enable react/require-default-props */
const AppNotification = forwardRef(
({ className, message, style, onClose }: Props, ref: ForwardedRef<HTMLDivElement>) => {
return (
<div ref={ref} className={classNames(styles.notification, className)} style={style}>
<InfoIcon className={styles.icon} />
<div className={styles.message}>{message}</div>
<TextLink text="action.got_it" className={styles.link} onClick={onClose} />
</div>
);
}
);
export default AppNotification;

View file

@ -0,0 +1,10 @@
@use '@/scss/underscore' as _;
.notification {
padding: _.unit(3) _.unit(4);
font: var(--font-body-2);
color: var(--color-type-primary);
margin: 0 auto _.unit(2);
background: var(--color-alert-99);
@include _.flex_row;
}

View file

@ -0,0 +1,18 @@
import classNames from 'classnames';
import type { TFuncKey } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import * as styles from './index.module.scss';
type Props = {
className?: string;
message: TFuncKey;
};
const InlineNotification = ({ className, message }: Props) => {
const { t } = useTranslation();
return <div className={classNames(styles.notification, className)}>{t(message)}</div>;
};
export default InlineNotification;

View file

@ -1,55 +0,0 @@
@use '@/scss/underscore' as _;
.notification {
padding: _.unit(3) _.unit(4);
font: var(--font-body-2);
color: var(--color-type-primary);
margin: 0 auto _.unit(2);
@include _.flex_row;
.icon {
width: 20px;
height: 20px;
margin-right: _.unit(3);
}
&.alert {
background: var(--color-alert-99);
.icon {
color: var(--color-alert-70);
}
}
&.info {
background: var(--color-neutral-variant-90);
.icon {
color: var(--color-neutral-variant-60);
}
}
}
.message {
flex: 1;
margin-right: _.unit(4);
}
.link {
max-width: 20%;
}
:global(body.desktop) {
.notification {
border-radius: var(--radius);
box-shadow: var(--color-shadow-1);
&.alert {
border: _.border(var(--color-alert-70));
}
&.info {
border: _.border(var(--color-neutral-variant-80));
}
}
}

View file

@ -1,25 +1,2 @@
import classNames from 'classnames';
import InfoIcon from '@/assets/icons/info-icon.svg';
import TextLink from '../TextLink';
import * as styles from './index.module.scss';
type Props = {
className?: string;
message: string;
onClose: () => void;
type?: 'info' | 'alert';
};
const Notification = ({ className, message, onClose, type = 'info' }: Props) => {
return (
<div className={classNames(styles.notification, styles[type], className)}>
<InfoIcon className={styles.icon} />
<div className={styles.message}>{message}</div>
<TextLink text="action.got_it" className={styles.link} onClick={onClose} />
</div>
);
};
export default Notification;
export { default as AppNotification } from './AppNotification';
export { default as InlineNotification } from './InlineNotification';

View file

@ -26,6 +26,10 @@
.title {
@include _.title;
}
.notification {
margin: 0 _.unit(-5) _.unit(6);
}
}
:global(body.desktop) {
@ -40,4 +44,11 @@
.title {
@include _.title_desktop;
}
.notification {
@include _.full-width;
margin-top: _.unit(6);
box-shadow: var(--color-shadow-1);
border-radius: var(--radius);
}
}

View file

@ -2,7 +2,9 @@ import { useTranslation } from 'react-i18next';
import type { TFuncKey } from 'react-i18next';
import NavBar from '@/components/NavBar';
import usePlatform from '@/hooks/use-platform';
import { InlineNotification } from '../Notification';
import * as styles from './index.module.scss';
type Props = {
@ -10,6 +12,7 @@ type Props = {
description?: TFuncKey;
titleProps?: Record<string, unknown>;
descriptionProps?: Record<string, unknown>;
notification?: TFuncKey;
children: React.ReactNode;
};
@ -18,13 +21,18 @@ const SecondaryPageWrapper = ({
description,
titleProps,
descriptionProps,
notification,
children,
}: Props) => {
const { t } = useTranslation();
const { isMobile } = usePlatform();
return (
<div className={styles.wrapper}>
<NavBar />
{isMobile && notification && (
<InlineNotification message={notification} className={styles.notification} />
)}
<div className={styles.container}>
<div className={styles.header}>
{title && <div className={styles.title}>{t(title, titleProps)}</div>}
@ -35,6 +43,9 @@ const SecondaryPageWrapper = ({
{children}
</div>
{!isMobile && notification && (
<InlineNotification message={notification} className={styles.notification} />
)}
</div>
);
};

View file

@ -85,5 +85,6 @@ body {
border-radius: 16px;
background: var(--color-bg-float);
box-shadow: var(--color-shadow-2);
overflow: hidden;
}
}

View file

@ -15,18 +15,8 @@
:global(body.desktop) {
.appNotification {
top: _.unit(-6);
left: 50%;
transform: translate(-50%, -100%);
transform: translate(-50%);
width: max-content;
}
}
@media screen and (max-height: 820px) {
:global(body.desktop) {
.appNotification {
top: _.unit(6);
transform: translate(-50%);
}
}
}

View file

@ -1,30 +0,0 @@
import { fireEvent } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import Notification from './index';
describe('Notification', () => {
it('render Notification', () => {
const notification = 'text notification';
const { queryByText, getByText } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
notification,
}}
>
<Notification />
</SettingsProvider>
);
expect(queryByText(notification)).not.toBeNull();
const closeButton = getByText('action.got_it');
fireEvent.click(closeButton);
expect(queryByText(notification)).toBeNull();
});
});

View file

@ -1,34 +1,66 @@
import { useCallback, useContext } from 'react';
import { useContext, useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import Notification from '@/components/Notification';
import { AppNotification as Notification } from '@/components/Notification';
import { PageContext } from '@/hooks/use-page-context';
import usePlatform from '@/hooks/use-platform';
import * as styles from './index.module.scss';
const AppNotification = () => {
const { experienceSettings, setExperienceSettings } = useContext(PageContext);
const notification = experienceSettings?.notification;
const { isMobile } = usePlatform();
const { experienceSettings } = useContext(PageContext);
const [notification, setNotification] = useState<string>();
const [topOffset, setTopOffset] = useState<number>();
const eleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (experienceSettings?.notification) {
setNotification(experienceSettings.notification);
}
}, [experienceSettings]);
const adjustNotificationPosition = useCallback(() => {
const mainEleOffsetTop = document.querySelector('main')?.offsetTop;
const elementHeight = eleRef.current?.offsetHeight;
if (mainEleOffsetTop !== undefined && elementHeight) {
const topSpace = mainEleOffsetTop - elementHeight - 24;
setTopOffset(Math.max(32, topSpace));
}
}, []);
useEffect(() => {
if (!notification || isMobile) {
return;
}
adjustNotificationPosition();
window.addEventListener('resize', adjustNotificationPosition);
return () => {
window.removeEventListener('resize', adjustNotificationPosition);
};
}, [adjustNotificationPosition, isMobile, notification]);
const onClose = useCallback(() => {
// Clear notification
setExperienceSettings((settings) => {
if (!settings) {
return;
}
return {
...settings,
notification: undefined,
};
});
}, [setExperienceSettings]);
setNotification('');
}, []);
if (!notification) {
return null;
}
return (
<Notification className={styles.appNotification} message={notification} onClose={onClose} />
return createPortal(
<Notification
ref={eleRef}
className={styles.appNotification}
message={notification}
style={isMobile ? undefined : { top: topOffset }}
onClose={onClose}
/>,
document.body
);
};

View file

@ -4,10 +4,10 @@ import type { ReactNode } from 'react';
import { useContext } from 'react';
import BrandingHeader from '@/components/BrandingHeader';
import AppNotification from '@/containers/AppNotification';
import { PageContext } from '@/hooks/use-page-context';
import { getLogoUrl } from '@/utils/logo';
import AppNotification from '../AppNotification';
import * as styles from './index.module.scss';
type Props = {
@ -34,9 +34,9 @@ const LandingPageContainer = ({ children, className }: Props) => {
logo={getLogoUrl({ theme, logoUrl, darkLogoUrl })}
/>
{children}
<AppNotification />
</div>
{platform === 'web' && <div className={styles.placeholderBottom} />}
<AppNotification />
</>
);
};

View file

@ -7,7 +7,10 @@ const SetPassword = () => {
const { setPassword } = useSetPassword();
return (
<SecondaryPageWrapper title="description.set_password">
<SecondaryPageWrapper
title="description.set_password"
notification="description.continue_with_more_information"
>
<SetPasswordForm autoFocus onSubmit={setPassword} />
</SecondaryPageWrapper>
);