UI for email subscription (#537)
* Add api methods for subscription * Small changes * little changes in remark.tsx * disallow pass className to Button and Input * Subscribe block * add RSS and Email subscription drobdowns * add hook useTheme * unify dropdown import/export * Change API * rename subscribe methods * add unsubscribe method * Add email subscription to settings and user * Refactor and add new steps * render by single component * add final step * add unsubscribe step it user is subscribed * Add tests * Update subscription logic * test without mocking redux methods but with mocking store * update user in store after subscribe and unsubscribe * little changes in subscription flow * fix drobdown size * Fix RSS subscription link for site * fix link * add test for RSS subscription * Fix showing email subscription * it don't show to unauth users * it don't show to anonymous users * it tested * isUserAnonymous is a bit rewrited * isUserAnonymous is tested * Make Email button visible for anon users * React X supporst Fragments thats why .babelrc changed * disabled button is more visible * fix hovering on disabled buttons * create mocks for tests * move email dropdown to __subscribe-by-email
This commit is contained in:
@@ -14,7 +14,7 @@ module.exports = {
|
||||
'@babel/preset-react',
|
||||
{
|
||||
pragma: 'h',
|
||||
pragmaFrag: 'div',
|
||||
pragmaFrag: 'Fragment',
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
@@ -275,6 +275,28 @@ export const uploadImage = (image: File): Promise<Image> => {
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Start process of email subscription to updates
|
||||
* @param emailAddress email for subscription
|
||||
*/
|
||||
export const emailVerificationForSubscribe = (emailAddress: string) =>
|
||||
fetcher.post({
|
||||
url: `/email/subscribe?site=${siteId}&address=${emailAddress}`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Confirmation of email subscription to updates
|
||||
* @param token confirmation token from email
|
||||
*/
|
||||
export const emailConfirmationForSubscribe = (token: string) =>
|
||||
fetcher.post({ url: `/email/confirm?site=${siteId}&tkn=${encodeURIComponent(token)}`, withCredentials: true });
|
||||
|
||||
/**
|
||||
* Decline current subscription to updates
|
||||
*/
|
||||
export const unsubscribeFromEmailUpdates = () => fetcher.delete({ url: `/email`, withCredentials: true });
|
||||
|
||||
export default {
|
||||
logIn,
|
||||
logOut,
|
||||
|
||||
@@ -28,6 +28,7 @@ export const StaticStore: StaticStoreType = {
|
||||
max_image_size: 0,
|
||||
simple_view: false,
|
||||
anon_vote: false,
|
||||
email_notifications: false,
|
||||
},
|
||||
query: querySettings as QuerySettingsType,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface User {
|
||||
admin: boolean;
|
||||
block: boolean;
|
||||
verified: boolean;
|
||||
email_subscription?: boolean;
|
||||
}
|
||||
|
||||
/** data which is used on user-info page */
|
||||
@@ -113,6 +114,7 @@ export interface Config {
|
||||
max_image_size: number;
|
||||
simple_view: boolean;
|
||||
anon_vote: boolean;
|
||||
email_notifications: boolean;
|
||||
}
|
||||
|
||||
export interface RemarkConfig {
|
||||
|
||||
@@ -11,7 +11,7 @@ import debounce from '@app/utils/debounce';
|
||||
import postMessage from '@app/utils/postMessage';
|
||||
import { StoreState } from '@app/store';
|
||||
import { ProviderState } from '@app/store/provider/reducers';
|
||||
import Dropdown, { DropdownItem } from '@app/components/dropdown';
|
||||
import { Dropdown, DropdownItem } from '@app/components/dropdown';
|
||||
import { Button } from '@app/components/button';
|
||||
|
||||
import { AnonymousLoginForm } from './__anonymous-login-form';
|
||||
|
||||
@@ -6,4 +6,9 @@
|
||||
&:hover {
|
||||
color: #06c5c5;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&:hover:disabled {
|
||||
color: #0aa;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
background: #06c5c5;
|
||||
}
|
||||
|
||||
&:hover&:disabled {
|
||||
&:hover:disabled {
|
||||
background: #259c9a;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,10 @@
|
||||
background: #22201c;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.button_theme_dark.button_kind_link {
|
||||
&:disabled,
|
||||
&:hover:disabled {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { forwardRef } from 'preact/compat';
|
||||
import b, { Mods, Mix } from 'bem-react-helper';
|
||||
import { Theme } from '@app/common/types';
|
||||
|
||||
interface Props extends Omit<JSX.HTMLAttributes, 'size'> {
|
||||
interface Props extends Omit<JSX.HTMLAttributes, 'size' | 'className'> {
|
||||
kind?: 'primary' | 'secondary' | 'link';
|
||||
size?: 'middle' | 'large';
|
||||
theme?: Theme;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
.comment-form__button {
|
||||
margin: 8px 8px 0 0;
|
||||
align-self: flex-start;
|
||||
|
||||
& + .comment-form__button {
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
.comment-form__subscribe-by-email {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
width: 200px;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.comment-form__subscribe-by-email_token {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.comment-form__subscribe-by-email_subscribed,
|
||||
.comment-form__subscribe-by-email_unsubscribed {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.comment-form__subscribe-by-email__title {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.comment-form__subscribe-by-email__button {
|
||||
margin-top: 10px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.comment-form__subscribe-by-email__preloader {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.comment-form__subscribe-by-email__token-input {
|
||||
resize: vertical;
|
||||
border: 1px solid #c4c4c4;
|
||||
padding: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.8em;
|
||||
font-weight: normal;
|
||||
line-height: 1.5;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px rgba(37, 156, 154, 0.4);
|
||||
border-color: #259c9a;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-form__subscribe-by-email__error {
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.comment-form__subscribe-by-email_theme_dark .comment-form__subscribe-by-email__error {
|
||||
background: #672323;
|
||||
color: #f98989;
|
||||
}
|
||||
|
||||
.comment-form__subscribe-by-email_theme_light .comment-form__subscribe-by-email__error {
|
||||
background: #ffd7d7;
|
||||
color: #9a0000;
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement } from 'preact';
|
||||
import { mount } from 'enzyme';
|
||||
import { act } from 'preact/test-utils';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Middleware } from 'redux';
|
||||
import createMockStore from 'redux-mock-store';
|
||||
|
||||
import '@app/testUtils/mockApi';
|
||||
import { user, anonymousUser } from '@app/testUtils/mocks/user';
|
||||
|
||||
import * as api from '@app/common/api';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
import { Input } from '@app/components/input';
|
||||
import { Button } from '@app/components/button';
|
||||
import { Dropdown } from '@app/components/dropdown';
|
||||
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
|
||||
|
||||
import { SubscribeByEmail, SubscribeByEmailForm } from './';
|
||||
|
||||
const initialStore = {
|
||||
user,
|
||||
theme: 'light',
|
||||
} as const;
|
||||
|
||||
const mockStore = createMockStore([] as Middleware[]);
|
||||
|
||||
const makeInputEvent = (value: string) => ({
|
||||
preventDefault: jest.fn(),
|
||||
target: {
|
||||
value,
|
||||
},
|
||||
});
|
||||
|
||||
describe('<SubscribeByEmail', () => {
|
||||
const createWrapper = (store: ReturnType<typeof mockStore> = mockStore(initialStore)) =>
|
||||
mount(
|
||||
<Provider store={store}>
|
||||
<SubscribeByEmail />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
it('should be rendered with disabled email button when user is anonymous', () => {
|
||||
const store = mockStore({ ...initialStore, user: anonymousUser });
|
||||
const wrapper = createWrapper(store);
|
||||
const dropdown = wrapper.find(Dropdown);
|
||||
|
||||
expect(dropdown.prop('disabled')).toEqual(true);
|
||||
expect(dropdown.prop('buttonTitle')).toEqual('Available only for registered users');
|
||||
});
|
||||
|
||||
it('should be rendered with enabled email button when user is registrated', () => {
|
||||
const store = mockStore(initialStore);
|
||||
const wrapper = createWrapper(store);
|
||||
const dropdown = wrapper.find(Dropdown);
|
||||
|
||||
expect(dropdown.prop('disabled')).toEqual(false);
|
||||
expect(dropdown.prop('buttonTitle')).toEqual('Subscribe by Email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('<SubscribeByEmailForm>', () => {
|
||||
const createWrapper = (store: ReturnType<typeof mockStore> = mockStore(initialStore)) =>
|
||||
mount(
|
||||
<Provider store={store}>
|
||||
<SubscribeByEmailForm />
|
||||
</Provider>
|
||||
);
|
||||
it('should render email form by default', () => {
|
||||
const store = mockStore(initialStore);
|
||||
const wrapper = createWrapper(store);
|
||||
const title = wrapper.find('.comment-form__subscribe-by-email__title');
|
||||
const button = wrapper.find(Button);
|
||||
|
||||
expect(title.text()).toEqual('Subscribe to replies');
|
||||
expect(button.prop('children')).toEqual('Submit');
|
||||
expect(button.prop('disabled')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should render subscribed state if user subscribed', () => {
|
||||
const store = mockStore({ ...initialStore, user: { email_subscription: true } });
|
||||
const wrapper = createWrapper(store);
|
||||
|
||||
expect(wrapper.find('.comment-form__subscribe-by-email_subscribed')).toHaveLength(1);
|
||||
expect(wrapper.text()).toStartWith('You are subscribed on updates by email');
|
||||
});
|
||||
|
||||
it('should pass throw subscribe process', async () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
const emailVerificationForSubscribe = jest.spyOn(api, 'emailVerificationForSubscribe');
|
||||
const emailConfirmationForSubscribe = jest.spyOn(api, 'emailConfirmationForSubscribe');
|
||||
const onInputEmail = wrapper.find(Input).prop('onInput');
|
||||
const form = wrapper.find('form');
|
||||
|
||||
expect(onInputEmail).toBeFunction();
|
||||
|
||||
act(() => onInputEmail(makeInputEvent('some@email.com')));
|
||||
|
||||
expect(form).toHaveLength(1);
|
||||
|
||||
form.simulate('submit');
|
||||
|
||||
expect(emailVerificationForSubscribe).toHaveBeenCalledWith('some@email.com');
|
||||
|
||||
await sleep(0);
|
||||
wrapper.update();
|
||||
|
||||
const textarea = wrapper.find(TextareaAutosize);
|
||||
const onInputToken = textarea.prop('onInput') as (e: any) => void;
|
||||
const button = wrapper.find(Button);
|
||||
|
||||
expect(textarea).toHaveLength(1);
|
||||
expect(onInputToken).toBeFunction();
|
||||
expect(button.at(0).text()).toEqual('Back');
|
||||
expect(button.at(1).text()).toEqual('Subscribe');
|
||||
|
||||
act(() => onInputToken(makeInputEvent('tokentokentoken')));
|
||||
|
||||
wrapper.find('form').simulate('submit');
|
||||
|
||||
expect(emailConfirmationForSubscribe).toHaveBeenCalledWith('tokentokentoken');
|
||||
|
||||
await sleep(0);
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.text()).toStartWith('You have been subscribed on updates by email');
|
||||
expect(wrapper.find(Button).prop('children')).toEqual('Unsubscribe');
|
||||
});
|
||||
|
||||
it('should pass throw unsubscribe process', async () => {
|
||||
const store = mockStore({ ...initialStore, user: { email_subscription: true } });
|
||||
const wrapper = createWrapper(store);
|
||||
const onClick = wrapper.find(Button).prop('onClick');
|
||||
const unsubscribeFromEmailUpdates = jest.spyOn(api, 'unsubscribeFromEmailUpdates');
|
||||
|
||||
expect(onClick).toBeFunction();
|
||||
|
||||
act(() => onClick());
|
||||
|
||||
expect(unsubscribeFromEmailUpdates).toHaveBeenCalled();
|
||||
|
||||
await sleep(0);
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.text()).toStartWith('You have been unsubscribed by email to updates');
|
||||
expect(wrapper.find(Button).prop('children')).toEqual('Close');
|
||||
});
|
||||
});
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, FunctionComponent } from 'preact';
|
||||
import { useState, useCallback, useEffect, useRef } from 'preact/hooks';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import b from 'bem-react-helper';
|
||||
|
||||
import { User } from '@app/common/types';
|
||||
import { StoreState } from '@app/store';
|
||||
import { setUserSubscribed } from '@app/store/user/actions';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
|
||||
import useTheme from '@app/hooks/useTheme';
|
||||
import { getHandleClickProps } from '@app/common/accessibility';
|
||||
import {
|
||||
emailVerificationForSubscribe,
|
||||
emailConfirmationForSubscribe,
|
||||
unsubscribeFromEmailUpdates,
|
||||
} from '@app/common/api';
|
||||
import { Input } from '@app/components/input';
|
||||
import { Button } from '@app/components/button';
|
||||
import { Dropdown } from '@app/components/dropdown';
|
||||
import { Preloader } from '@app/components/preloader';
|
||||
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
|
||||
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
|
||||
|
||||
const emailRegex = /[^@]+@[^.]+\..+/;
|
||||
|
||||
enum Step {
|
||||
Email,
|
||||
Token,
|
||||
Final,
|
||||
Close,
|
||||
Subscribed,
|
||||
Unsubscribed,
|
||||
}
|
||||
|
||||
const renderEmailPart = (
|
||||
loading: boolean,
|
||||
emailAddress: string,
|
||||
handleChangeEmail: (e: Event) => void,
|
||||
emailAddressRef: ReturnType<typeof useRef>
|
||||
) => [
|
||||
<div className="comment-form__subscribe-by-email__title">Subscribe to replies</div>,
|
||||
<Input
|
||||
ref={emailAddressRef}
|
||||
mix="comment-form__subscribe-by-email__input"
|
||||
placeholder="Email"
|
||||
value={emailAddress}
|
||||
onInput={handleChangeEmail}
|
||||
disabled={loading}
|
||||
/>,
|
||||
];
|
||||
|
||||
const renderTokenPart = (
|
||||
loading: boolean,
|
||||
token: string,
|
||||
handleChangeToken: (e: Event) => void,
|
||||
setEmailStep: () => void
|
||||
) => [
|
||||
<Button kind="link" mix="auth-panel-email-login-form__back-button" {...getHandleClickProps(setEmailStep)}>
|
||||
Back
|
||||
</Button>,
|
||||
<TextareaAutosize
|
||||
className="comment-form__subscribe-by-email__token-input"
|
||||
placeholder="Token"
|
||||
autofocus
|
||||
onInput={handleChangeToken}
|
||||
disabled={loading}
|
||||
value={token}
|
||||
/>,
|
||||
];
|
||||
|
||||
export const SubscribeByEmailForm: FunctionComponent = () => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const subscribed = useSelector<StoreState, boolean>(({ user }) =>
|
||||
user === null ? false : Boolean(user.email_subscription)
|
||||
);
|
||||
const emailAddressRef = useRef<HTMLInputElement>();
|
||||
const previousStep = useRef<Step | null>(null);
|
||||
|
||||
const [step, setStep] = useState(subscribed ? Step.Subscribed : Step.Email);
|
||||
|
||||
const [token, setToken] = useState('');
|
||||
const [emailAddress, setEmailAddress] = useState('');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleChangeEmail = useCallback((e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setEmailAddress(value);
|
||||
}, []);
|
||||
|
||||
const handleChangeToken = useCallback((e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setToken(value);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
switch (step) {
|
||||
case Step.Email:
|
||||
await emailVerificationForSubscribe(emailAddress);
|
||||
setStep(Step.Token);
|
||||
break;
|
||||
case Step.Token:
|
||||
await emailConfirmationForSubscribe(token);
|
||||
dispatch(setUserSubscribed(true));
|
||||
previousStep.current = Step.Token;
|
||||
setStep(Step.Subscribed);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
setError(extractErrorMessageFromResponse(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValidEmailAddress = emailRegex.test(emailAddress);
|
||||
|
||||
useEffect(() => {
|
||||
if (emailAddressRef.current) {
|
||||
emailAddressRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setEmailStep = useCallback(async () => {
|
||||
await sleep(0);
|
||||
setStep(Step.Email);
|
||||
}, [setStep]);
|
||||
|
||||
/**
|
||||
* It needs for dropdown closing by click on button
|
||||
* More info below
|
||||
*/
|
||||
if (step === Step.Close) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (step === Step.Subscribed) {
|
||||
const handleUnsubscribe = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await unsubscribeFromEmailUpdates();
|
||||
dispatch(setUserSubscribed(false));
|
||||
previousStep.current = Step.Subscribed;
|
||||
setStep(Step.Unsubscribed);
|
||||
} catch (e) {
|
||||
setError(extractErrorMessageFromResponse(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setLoading, setStep, setError]);
|
||||
|
||||
const text =
|
||||
previousStep.current === Step.Token
|
||||
? 'You have been subscribed on updates by email'
|
||||
: 'You are subscribed on updates by email';
|
||||
|
||||
return (
|
||||
<div className={b('comment-form__subscribe-by-email', { mods: { subscribed: true } })}>
|
||||
{text}
|
||||
<Button
|
||||
kind="primary"
|
||||
size="middle"
|
||||
mix="comment-form__subscribe-by-email__button"
|
||||
theme={theme}
|
||||
onClick={handleUnsubscribe}
|
||||
>
|
||||
Unsubscribe
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === Step.Unsubscribed) {
|
||||
/**
|
||||
* It works because click on button changes step
|
||||
* And dropdown doesn't find event target in rerendered view
|
||||
* NOTE: If you can suggest more elegant solve you can open issue or PR
|
||||
*/
|
||||
|
||||
return (
|
||||
<div className={b('comment-form__subscribe-by-email', { mods: { unsubscribed: true } })}>
|
||||
You have been unsubscribed by email to updates
|
||||
<Button
|
||||
kind="primary"
|
||||
size="middle"
|
||||
mix="comment-form__subscribe-by-email__button"
|
||||
theme={theme}
|
||||
onClick={() => setStep(Step.Close)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const buttonLabel = step === Step.Email ? 'Submit' : 'Subscribe';
|
||||
|
||||
return (
|
||||
<form className={b('comment-form__subscribe-by-email', {}, { theme })} onSubmit={handleSubmit}>
|
||||
{step === Step.Email && renderEmailPart(loading, emailAddress, handleChangeEmail, emailAddressRef)}
|
||||
{step === Step.Token && renderTokenPart(loading, token, handleChangeToken, setEmailStep)}
|
||||
{error !== null && (
|
||||
<div className="comment-form__subscribe-by-email__error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
mix="comment-form__subscribe-by-email__button"
|
||||
kind="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={!isValidEmailAddress || loading}
|
||||
>
|
||||
{loading ? <Preloader mix="comment-form__subscribe-by-email__preloader" /> : buttonLabel}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const SubscribeByEmail: FunctionComponent = () => {
|
||||
const theme = useTheme();
|
||||
const user = useSelector<StoreState, User | null>(({ user }) => user);
|
||||
const isAnonymous = isUserAnonymous(user);
|
||||
const buttonTitle = isAnonymous ? 'Available only for registered users' : 'Subscribe by Email';
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
mix="comment-form__email-dropdown"
|
||||
title="Email"
|
||||
theme={theme}
|
||||
disabled={isAnonymous}
|
||||
buttonTitle={buttonTitle}
|
||||
>
|
||||
<SubscribeByEmailForm />
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SubscribeByEmail, SubscribeByEmailForm } from './comment-form__subscribe-by-email';
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
.comment-form__rss-dropdown {
|
||||
text-align: left;
|
||||
}
|
||||
.comment-form__rss-dropdown__link {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
color: #0aa;
|
||||
|
||||
&:hover {
|
||||
color: #06c5c5;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement } from 'preact';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { SubscribeByRSS, createSubscribeUrl } from './';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(fn => fn({ theme: 'light' })),
|
||||
}));
|
||||
|
||||
describe('<SubscribeByRSS/>', () => {
|
||||
let wrapper: ReturnType<typeof shallow>;
|
||||
|
||||
beforeAll(() => {
|
||||
wrapper = shallow(<SubscribeByRSS userId="user-1" />);
|
||||
});
|
||||
|
||||
it('should be render links in dropdown', () => {
|
||||
expect(wrapper.find('.comment-form__rss-dropdown__link')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should have userId in site link', () => {
|
||||
expect(
|
||||
wrapper
|
||||
.find('.comment-form__rss-dropdown__link')
|
||||
.at(1)
|
||||
.prop('href')
|
||||
).toEqual(createSubscribeUrl('site', '&user=user-1'));
|
||||
});
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, FunctionComponent } from 'preact';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
|
||||
import useTheme from '@app/hooks/useTheme';
|
||||
import { siteId, url } from '@app/common/settings';
|
||||
import { BASE_URL, API_BASE } from '@app/common/constants';
|
||||
import { Dropdown, DropdownItem } from '@app/components/dropdown';
|
||||
|
||||
export const createSubscribeUrl = (type: 'post' | 'site' | 'reply', urlParams: string = '') =>
|
||||
`${BASE_URL}${API_BASE}/rss/${type}?site=${siteId}${urlParams}`;
|
||||
|
||||
export const SubscribeByRSS: FunctionComponent<{ userId: string | null }> = ({ userId }) => {
|
||||
const theme = useTheme();
|
||||
const items: Array<[string, string]> = useMemo(
|
||||
() => [
|
||||
[createSubscribeUrl('post'), 'Thread'],
|
||||
[createSubscribeUrl('site', `&user=${userId}`), 'Site'],
|
||||
[createSubscribeUrl('reply', `&url=${url}`), 'Replies'],
|
||||
],
|
||||
[userId]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
title="RSS"
|
||||
titleClass="comment-form__rss-dropdown__title"
|
||||
buttonTitle="Subscribe by RSS"
|
||||
mix="comment-form__rss-dropdown"
|
||||
theme={theme}
|
||||
>
|
||||
{items.map(([href, label]) => (
|
||||
<DropdownItem>
|
||||
<a href={href} className="comment-form__rss-dropdown__link" target="_blank">
|
||||
{label}
|
||||
</a>
|
||||
</DropdownItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SubscribeByRSS, createSubscribeUrl } from './comment-form__subscribe-by-rss';
|
||||
@@ -6,3 +6,11 @@
|
||||
border-width: 6px 12px 12px 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.comment-form__dropdown_rss {
|
||||
text-align: left;
|
||||
|
||||
.dropdown__content {
|
||||
width: 8em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,45 @@
|
||||
import { createElement } from 'preact';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { user } from '@app/testUtils/mocks/user';
|
||||
import { StaticStore } from '@app/common/static_store';
|
||||
|
||||
import { CommentForm, Props } from './comment-form';
|
||||
import { SubscribeByEmail } from './__subscribe-by-email';
|
||||
|
||||
const DEFAULT_PROPS: Readonly<Props> = {
|
||||
mode: 'main',
|
||||
theme: 'light',
|
||||
onSubmit: () => Promise.resolve(),
|
||||
getPreview: () => Promise.resolve(''),
|
||||
user: null,
|
||||
};
|
||||
|
||||
describe('<CommentForm />', () => {
|
||||
it('shoud render without control panel, preview button, and rss links in "simple view" mode', () => {
|
||||
const element = shallow(<CommentForm {...({ simpleView: true } as Props)} />);
|
||||
it('should render without control panel, preview button, and rss links in "simple view" mode', () => {
|
||||
const props = { ...DEFAULT_PROPS, simpleView: true };
|
||||
const wrapper = shallow(<CommentForm {...props} />);
|
||||
|
||||
expect(element.exists('.comment-form__control-panel')).toEqual(false);
|
||||
expect(element.exists('.comment-form__button_type_preview')).toEqual(false);
|
||||
expect(element.exists('.comment-form__rss')).toEqual(false);
|
||||
expect(wrapper.exists('.comment-form__control-panel')).toEqual(false);
|
||||
expect(wrapper.exists('.comment-form__button_type_preview')).toEqual(false);
|
||||
expect(wrapper.exists('.comment-form__rss')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should be rendered with email subscription button', () => {
|
||||
StaticStore.config.email_notifications = true;
|
||||
|
||||
const props = { ...DEFAULT_PROPS, user };
|
||||
const wrapper = shallow(<CommentForm {...props} />);
|
||||
|
||||
expect(wrapper.exists(SubscribeByEmail)).toEqual(true);
|
||||
});
|
||||
|
||||
it('should be rendered without email subscription button when email_notifications disabled', () => {
|
||||
StaticStore.config.email_notifications = false;
|
||||
|
||||
const props = { ...DEFAULT_PROPS, user };
|
||||
const wrapper = shallow(<CommentForm {...props} />);
|
||||
|
||||
expect(wrapper.exists(SubscribeByEmail)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, Component, createRef } from 'preact';
|
||||
import { createElement, Component, createRef, Fragment } from 'preact';
|
||||
import b, { Mix } from 'bem-react-helper';
|
||||
|
||||
import { User, Theme, Image, ApiError } from '@app/common/types';
|
||||
import { BASE_URL, API_BASE } from '@app/common/constants';
|
||||
import { StaticStore } from '@app/common/static_store';
|
||||
import { siteId, url, pageTitle } from '@app/common/settings';
|
||||
import { pageTitle } from '@app/common/settings';
|
||||
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
import { replaceSelection } from '@app/utils/replaceSelection';
|
||||
import { Button } from '@app/components/button';
|
||||
|
||||
import { SubscribeByEmail } from './__subscribe-by-email';
|
||||
import { SubscribeByRSS } from './__subscribe-by-rss';
|
||||
|
||||
import MarkdownToolbar from './markdown-toolbar';
|
||||
import TextareaAutosize from './textarea-autosize';
|
||||
|
||||
const RSS_THREAD_URL = `${BASE_URL}${API_BASE}/rss/post?site=${siteId}&url=${url}`;
|
||||
const RSS_SITE_URL = `${BASE_URL}${API_BASE}/rss/site?site=${siteId}`;
|
||||
const RSS_REPLIES_URL = `${BASE_URL}${API_BASE}/rss/reply?site=${siteId}&user=`;
|
||||
|
||||
let textareaId = 0;
|
||||
|
||||
export interface Props {
|
||||
/** user id for rss link generation */
|
||||
userId?: User['id'];
|
||||
user: User | null;
|
||||
errorMessage?: string;
|
||||
value?: string;
|
||||
mix?: Mix;
|
||||
@@ -101,10 +98,13 @@ export class CommentForm extends Component<Props, State> {
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: Props, nextState: State) {
|
||||
const userId = this.props.user !== null && this.props.user.id;
|
||||
const nextUserId = nextProps.user !== null && nextProps.user.id;
|
||||
|
||||
return (
|
||||
nextUserId !== userId ||
|
||||
nextProps.mode !== this.props.mode ||
|
||||
nextProps.theme !== this.props.theme ||
|
||||
nextProps.userId !== this.props.userId ||
|
||||
nextProps.value !== this.props.value ||
|
||||
nextProps.errorMessage !== this.props.errorMessage ||
|
||||
nextState !== this.state
|
||||
@@ -417,22 +417,17 @@ export class CommentForm extends Component<Props, State> {
|
||||
Styling with{' '}
|
||||
<a className="comment-form__markdown-link" target="_blank" href="markdown-help.html">
|
||||
Markdown
|
||||
</a>{' '}
|
||||
is supported
|
||||
</a>
|
||||
{' is supported'}
|
||||
</div>
|
||||
Subscribe to the{' '}
|
||||
<a className="comment-form__rss-link" href={RSS_THREAD_URL} target="_blank">
|
||||
Thread
|
||||
</a>
|
||||
{', '}
|
||||
<a className="comment-form__rss-link" href={RSS_SITE_URL} target="_blank">
|
||||
Site
|
||||
</a>{' '}
|
||||
or
|
||||
<a className="comment-form__rss-link" href={RSS_REPLIES_URL + props.userId} target="_blank">
|
||||
Replies
|
||||
</a>{' '}
|
||||
by RSS
|
||||
{'Subscribe by '}
|
||||
<SubscribeByRSS userId={props.user !== null ? props.user.id : null} />
|
||||
{StaticStore.config.email_notifications && (
|
||||
<Fragment>
|
||||
{' or '}
|
||||
<SubscribeByEmail />
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ import './__rss-link/comment-form__rss-link.scss';
|
||||
import './__markdown/comment-form__markdown.scss';
|
||||
import './__markdown-link/comment-form__markdown-link.scss';
|
||||
import './__markdown-toolbar/comment-form__markdown-toolbar.scss';
|
||||
import './__subscribe-by-email/comment-form__subscribe-by-email.scss';
|
||||
import './__subscribe-by-rss/comment-form__subscribe-by-rss.scss';
|
||||
|
||||
import './_theme/_dark/comment-form_theme_dark.scss';
|
||||
import './_theme/_light/comment-form_theme_light.scss';
|
||||
|
||||
@@ -725,6 +725,7 @@ export class Comment extends Component<Props, State> {
|
||||
|
||||
{isReplying && props.view === 'main' && (
|
||||
<CommentForm
|
||||
user={props.user}
|
||||
theme={props.theme}
|
||||
value=""
|
||||
mode="reply"
|
||||
@@ -740,6 +741,7 @@ export class Comment extends Component<Props, State> {
|
||||
|
||||
{isEditing && props.view === 'main' && (
|
||||
<CommentForm
|
||||
user={props.user}
|
||||
theme={props.theme}
|
||||
value={o.orig}
|
||||
mode="edit"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
top: 100%;
|
||||
left: 0%;
|
||||
transform: translate(-0.5em, 5px);
|
||||
min-width: 170px;
|
||||
min-width: 120px;
|
||||
max-width: 260px;
|
||||
border: 2px solid #259c9a;
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, JSX, RenderableProps } from 'preact';
|
||||
import { createElement, JSX, FunctionComponent } from 'preact';
|
||||
import b from 'bem-react-helper';
|
||||
|
||||
interface Props {
|
||||
export interface Props extends JSX.HTMLAttributes {
|
||||
separator?: boolean;
|
||||
}
|
||||
|
||||
export default function DropdownItem(props: RenderableProps<Props> & JSX.HTMLAttributes & { separator?: boolean }) {
|
||||
const { children, separator = false } = props;
|
||||
|
||||
return <div className={b('dropdown__item', {}, { separator })}>{children}</div>;
|
||||
}
|
||||
export const DropdownItem: FunctionComponent<Props> = ({ children, separator = false }) => (
|
||||
<div className={b('dropdown__item', {}, { separator })}>{children}</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
import Dropdown__Item from './dropdown__item';
|
||||
|
||||
export default Dropdown__Item;
|
||||
export { DropdownItem } from './dropdown__item';
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
/** @jsx createElement */
|
||||
import { createElement, Component, createRef } from 'preact';
|
||||
import { createElement, Component, createRef, RenderableProps } from 'preact';
|
||||
import b from 'bem-react-helper';
|
||||
|
||||
import { Theme } from '@app/common/types';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
import { Button } from '@app/components/button';
|
||||
|
||||
interface Props {
|
||||
type Props = RenderableProps<{
|
||||
title: string;
|
||||
titleClass?: string;
|
||||
heading?: string;
|
||||
isActive?: boolean;
|
||||
disabled?: boolean;
|
||||
buttonTitle?: string;
|
||||
onTitleClick?: () => void;
|
||||
mix?: string;
|
||||
theme: Theme;
|
||||
onOpen?: (root: HTMLDivElement) => unknown;
|
||||
onClose?: (root: HTMLDivElement) => unknown;
|
||||
}
|
||||
}>;
|
||||
|
||||
interface State {
|
||||
isActive: boolean;
|
||||
contentTranslateX: number;
|
||||
}
|
||||
|
||||
export default class Dropdown extends Component<Props, State> {
|
||||
export class Dropdown extends Component<Props, State> {
|
||||
rootNode = createRef<HTMLDivElement>();
|
||||
storedDocumentHeight: string | null = null;
|
||||
storedDocumentHeightSet: boolean = false;
|
||||
@@ -115,7 +117,7 @@ export default class Dropdown extends Component<Props, State> {
|
||||
// TODO: use ref
|
||||
const dc = this.rootNode.current.querySelector<HTMLDivElement>('.dropdown__content');
|
||||
if (!dc) return;
|
||||
await sleep(10);
|
||||
await sleep(0);
|
||||
const rect = dc.getBoundingClientRect();
|
||||
if (rect.left > 0) {
|
||||
const wWindow = window.innerWidth;
|
||||
@@ -177,10 +179,7 @@ export default class Dropdown extends Component<Props, State> {
|
||||
window.removeEventListener('message', this.receiveMessage);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { title, titleClass, heading, children, mix, theme } = this.props;
|
||||
const { isActive } = this.state;
|
||||
|
||||
render({ title, titleClass, heading, children, mix, theme, disabled, buttonTitle }: Props, { isActive }: State) {
|
||||
return (
|
||||
<div className={b('dropdown', { mix }, { theme, active: isActive })} ref={this.rootNode}>
|
||||
<Button
|
||||
@@ -190,19 +189,22 @@ export default class Dropdown extends Component<Props, State> {
|
||||
theme={theme}
|
||||
mix={['dropdown__title', titleClass]}
|
||||
kind="link"
|
||||
disabled={disabled}
|
||||
title={buttonTitle}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
className="dropdown__content"
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
style={{ transform: `translateX(${this.state.contentTranslateX}px)` }}
|
||||
>
|
||||
{heading && <div className="dropdown__heading">{heading}</div>}
|
||||
<div className="dropdown__items">{children}</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div
|
||||
className="dropdown__content"
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
style={{ transform: `translateX(${this.state.contentTranslateX}px)` }}
|
||||
>
|
||||
{heading && <div className="dropdown__heading">{heading}</div>}
|
||||
<div className="dropdown__items">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import Dropdown from './dropdown';
|
||||
|
||||
export default Dropdown;
|
||||
|
||||
export { default as DropdownItem } from './__item';
|
||||
export { Dropdown } from './dropdown';
|
||||
export { DropdownItem } from './__item';
|
||||
|
||||
import './dropdown.scss';
|
||||
import './_active/dropdown_active.scss';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { forwardRef } from 'preact/compat';
|
||||
import b, { Mods, Mix } from 'bem-react-helper';
|
||||
import { Theme } from '@app/common/types';
|
||||
|
||||
interface Props extends JSX.HTMLAttributes {
|
||||
interface Props extends Omit<JSX.HTMLAttributes, 'className'> {
|
||||
kind?: 'primary' | 'secondary';
|
||||
theme?: Theme;
|
||||
mods?: Mods;
|
||||
|
||||
@@ -246,7 +246,7 @@ export class Root extends Component<Props, State> {
|
||||
theme={props.theme}
|
||||
mix="root__input"
|
||||
mode="main"
|
||||
userId={this.props.user!.id}
|
||||
user={props.user}
|
||||
onSubmit={(text, title) => this.props.addComment(text, title)}
|
||||
getPreview={this.props.getPreview}
|
||||
uploadImage={imageUploadHandler}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { StoreState } from '@app/store';
|
||||
import { Theme } from '@app/common/types';
|
||||
|
||||
export default function useTheme() {
|
||||
const theme = useSelector<StoreState, Theme>(({ theme }) => theme);
|
||||
|
||||
return theme;
|
||||
}
|
||||
+9
-10
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-console */
|
||||
/** @jsx createElement */
|
||||
import loadPolyfills from '@app/common/polyfills';
|
||||
import { createElement, render } from 'preact';
|
||||
@@ -34,7 +33,7 @@ async function init(): Promise<void> {
|
||||
const node = document.getElementById(NODE_ID);
|
||||
|
||||
if (!node) {
|
||||
console.error("Remark42: Can't find root node.");
|
||||
console.error("Remark42: Can't find root node."); // eslint-disable-line no-console
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,7 +59,7 @@ async function init(): Promise<void> {
|
||||
StaticStore.config = await api.getConfig();
|
||||
|
||||
if (params.page === 'user-info') {
|
||||
render(
|
||||
return render(
|
||||
<div id={NODE_ID}>
|
||||
<div className="root root_user-info">
|
||||
<Provider store={reduxStore}>
|
||||
@@ -70,12 +69,12 @@ async function init(): Promise<void> {
|
||||
</div>,
|
||||
node
|
||||
);
|
||||
} else {
|
||||
render(
|
||||
<Provider store={reduxStore}>
|
||||
<ConnectedRoot />
|
||||
</Provider>,
|
||||
node
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<Provider store={reduxStore}>
|
||||
<ConnectedRoot />
|
||||
</Provider>,
|
||||
node
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
USER_HIDELIST_SET,
|
||||
USER_HIDE,
|
||||
USER_UNHIDE,
|
||||
USER_SUBSCRIPTION_SET,
|
||||
SETTINGS_VISIBLE_SET,
|
||||
} from './types';
|
||||
import { unsetCommentMode } from '../comments/actions';
|
||||
@@ -155,3 +156,8 @@ export const setSettingsVisibility = (state: boolean): StoreAction<boolean> => d
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
export const setUserSubscribed = (isSubscribed: boolean) => ({
|
||||
type: USER_SUBSCRIPTION_SET,
|
||||
payload: isSubscribed,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
USER_HIDELIST_SET,
|
||||
USER_HIDE,
|
||||
USER_UNHIDE,
|
||||
USER_SUBSCRIPTION_SET,
|
||||
} from './types';
|
||||
|
||||
export const user = (state: User | null = null, action: USER_ACTIONS): User | null => {
|
||||
@@ -18,6 +19,16 @@ export const user = (state: User | null = null, action: USER_ACTIONS): User | nu
|
||||
case USER_SET: {
|
||||
return action.user;
|
||||
}
|
||||
case USER_SUBSCRIPTION_SET: {
|
||||
if (state === null) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
email_subscription: action.payload,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,13 @@ export interface SETTINGS_VISIBLE_SET_ACTION {
|
||||
state: boolean;
|
||||
}
|
||||
|
||||
export const USER_SUBSCRIPTION_SET = 'USER_SUBSCRIPTION/SET';
|
||||
|
||||
export interface USER_SUBSCRIPTION_SET_ACTION {
|
||||
type: typeof USER_SUBSCRIPTION_SET;
|
||||
payload: boolean;
|
||||
}
|
||||
|
||||
export type USER_ACTIONS =
|
||||
| USER_SET_ACTION
|
||||
| USER_BANLIST_SET_ACTION
|
||||
@@ -64,4 +71,5 @@ export type USER_ACTIONS =
|
||||
| USER_HIDELIST_SET_ACTION
|
||||
| USER_HIDE_ACTION
|
||||
| USER_UNHIDE_ACTION
|
||||
| SETTINGS_VISIBLE_SET_ACTION;
|
||||
| SETTINGS_VISIBLE_SET_ACTION
|
||||
| USER_SUBSCRIPTION_SET_ACTION;
|
||||
|
||||
@@ -23,5 +23,6 @@ beforeEach(() => {
|
||||
version: 'jest-test',
|
||||
simple_view: false,
|
||||
anon_vote: false,
|
||||
email_notifications: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
jest.mock('@app/common/api');
|
||||
@@ -0,0 +1,19 @@
|
||||
import { User } from '@app/common/types';
|
||||
|
||||
const user: Readonly<User> = {
|
||||
id: 'email_1',
|
||||
name: 'John',
|
||||
picture: 'some_picture',
|
||||
admin: false,
|
||||
ip: '127.0.0.1',
|
||||
block: false,
|
||||
verified: false,
|
||||
email_subscription: false,
|
||||
};
|
||||
|
||||
const anonymousUser: Readonly<User> = {
|
||||
...user,
|
||||
id: 'anonymous_1',
|
||||
};
|
||||
|
||||
export { user, anonymousUser };
|
||||
@@ -47,7 +47,8 @@ export type FetcherError =
|
||||
};
|
||||
|
||||
export function extractErrorMessageFromResponse(response: FetcherError): string {
|
||||
const defaultErrorMessage = 'Something went wrong. Please try again a bit later.';
|
||||
const defaultErrorMessage = errorMessageForCodes.get(0) as string;
|
||||
|
||||
if (!response) {
|
||||
return defaultErrorMessage;
|
||||
}
|
||||
@@ -60,13 +61,13 @@ export function extractErrorMessageFromResponse(response: FetcherError): string
|
||||
return response.error;
|
||||
}
|
||||
|
||||
if (typeof response.details === 'string') {
|
||||
return response.details.charAt(0).toUpperCase() + response.details.substring(1);
|
||||
}
|
||||
|
||||
if (typeof response.code === 'number' && errorMessageForCodes.has(response.code)) {
|
||||
return errorMessageForCodes.get(response.code)!;
|
||||
}
|
||||
|
||||
if (typeof response.details === 'string') {
|
||||
return response.details;
|
||||
}
|
||||
|
||||
return defaultErrorMessage;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { isUserAnonymous } from './isUserAnonymous';
|
||||
import { User } from '@app/common/types';
|
||||
|
||||
describe('isUserAnonymous', () => {
|
||||
test('user is anonymous', () => {
|
||||
expect(isUserAnonymous(null)).toEqual(true);
|
||||
expect(isUserAnonymous({ id: 'anonymous_1' } as User)).toEqual(true);
|
||||
});
|
||||
|
||||
test('user is not anonymous', () => {
|
||||
expect(isUserAnonymous({ id: 'email_1' } as User)).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,6 @@ import { User } from '@app/common/types';
|
||||
/**
|
||||
* Defines whether current client is logged in via `Anonymous provider`
|
||||
*/
|
||||
export function isUserAnonymous(user?: User | null): boolean {
|
||||
return user! && user!.id.substr(0, 10) === 'anonymous_';
|
||||
export function isUserAnonymous(user: User | null) {
|
||||
return user === null || user.id.substr(0, 10) === 'anonymous_';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user