Signed-off-by: marcin mikołajczak <git@mkljczk.pl>environments/review-events-5jp5it/deployments/1372
commit
803dada3e0
@ -0,0 +1,21 @@
|
||||
import classNames from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
interface IOutlineBox extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode,
|
||||
className?: string,
|
||||
}
|
||||
|
||||
/** Wraps children in a container with an outline. */
|
||||
const OutlineBox: React.FC<IOutlineBox> = ({ children, className, ...rest }) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames('p-4 rounded-lg border border-solid border-gray-300 dark:border-gray-800', className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineBox;
|
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../jest/test-helpers';
|
||||
import ModerationOverlay from '../moderation-overlay';
|
||||
|
||||
describe('<ModerationOverlay />', () => {
|
||||
it('defaults to enabled', () => {
|
||||
render(<ModerationOverlay />);
|
||||
expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Content Under Review');
|
||||
});
|
||||
|
||||
it('can be toggled', () => {
|
||||
render(<ModerationOverlay />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('button'));
|
||||
expect(screen.getByTestId('moderation-overlay')).not.toHaveTextContent('Content Under Review');
|
||||
expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Hide');
|
||||
});
|
||||
});
|
@ -0,0 +1,93 @@
|
||||
import classNames from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||
|
||||
import { Button, HStack, Text } from '../ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
hide: { id: 'moderation_overlay.hide', defaultMessage: 'Hide' },
|
||||
title: { id: 'moderation_overlay.title', defaultMessage: 'Content Under Review' },
|
||||
subtitle: { id: 'moderation_overlay.subtitle', defaultMessage: 'This Post has been sent to Moderation for review and is only visible to you. If you believe this is an error please contact Support.' },
|
||||
contact: { id: 'moderation_overlay.contact', defaultMessage: 'Contact' },
|
||||
show: { id: 'moderation_overlay.show', defaultMessage: 'Show Content' },
|
||||
});
|
||||
|
||||
const ModerationOverlay = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { links } = useSoapboxConfig();
|
||||
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
|
||||
const toggleVisibility = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
setVisible((prevValue) => !prevValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('absolute z-40', {
|
||||
'cursor-default backdrop-blur-lg rounded-lg w-full h-full border-0 flex justify-center items-center': !visible,
|
||||
'bg-gray-800/75 inset-0': !visible,
|
||||
'top-1 left-1': visible,
|
||||
})}
|
||||
data-testid='moderation-overlay'
|
||||
>
|
||||
{visible ? (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.hide)}
|
||||
icon={require('@tabler/icons/eye-off.svg')}
|
||||
onClick={toggleVisibility}
|
||||
theme='transparent'
|
||||
size='sm'
|
||||
/>
|
||||
) : (
|
||||
<div className='text-center w-3/4 mx-auto space-y-4'>
|
||||
<div className='space-y-1'>
|
||||
<Text theme='white' weight='semibold'>
|
||||
{intl.formatMessage(messages.title)}
|
||||
</Text>
|
||||
|
||||
<Text theme='white' size='sm' weight='medium'>
|
||||
{intl.formatMessage(messages.subtitle)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<HStack alignItems='center' justifyContent='center' space={2}>
|
||||
{links.get('support') && (
|
||||
<a
|
||||
href={links.get('support')}
|
||||
target='_blank'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/headset.svg')}
|
||||
>
|
||||
{intl.formatMessage(messages.contact)}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/eye.svg')}
|
||||
onClick={toggleVisibility}
|
||||
>
|
||||
{intl.formatMessage(messages.show)}
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationOverlay;
|
@ -1,119 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { moveAccount } from 'soapbox/actions/security';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
// import Column from 'soapbox/features/ui/components/column';
|
||||
import { Button, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.migration', defaultMessage: 'Account migration' },
|
||||
submit: { id: 'migration.submit', defaultMessage: 'Move followers' },
|
||||
moveAccountSuccess: { id: 'migration.move_account.success', defaultMessage: 'Account successfully moved.' },
|
||||
moveAccountFail: { id: 'migration.move_account.fail', defaultMessage: 'Account migration failed.' },
|
||||
acctFieldLabel: { id: 'migration.fields.acct.label', defaultMessage: 'Handle of the new account' },
|
||||
acctFieldPlaceholder: { id: 'migration.fields.acct.placeholder', defaultMessage: 'username@domain' },
|
||||
currentPasswordFieldLabel: { id: 'migration.fields.confirm_password.label', defaultMessage: 'Current password' },
|
||||
});
|
||||
|
||||
export default @connect()
|
||||
@injectIntl
|
||||
class Migration extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
targetAccount: '',
|
||||
password: '',
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
handleInputChange = e => {
|
||||
this.setState({ [e.target.name]: e.target.value });
|
||||
}
|
||||
|
||||
clearForm = () => {
|
||||
this.setState({ targetAccount: '', password: '' });
|
||||
}
|
||||
|
||||
handleSubmit = e => {
|
||||
const { targetAccount, password } = this.state;
|
||||
const { dispatch, intl } = this.props;
|
||||
this.setState({ isLoading: true });
|
||||
return dispatch(moveAccount(targetAccount, password)).then(() => {
|
||||
this.clearForm();
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.moveAccountSuccess)));
|
||||
}).catch(error => {
|
||||
dispatch(snackbar.error(intl.formatMessage(messages.moveAccountFail)));
|
||||
}).then(() => {
|
||||
this.setState({ isLoading: false });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl } = this.props;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='migration.hint'
|
||||
defaultMessage='This will move your followers to the new account. No other data will be moved. To perform migration, you need to {link} on your new account first.'
|
||||
values={{
|
||||
link: (
|
||||
<Link
|
||||
className='hover:underline text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-500'
|
||||
to='/settings/aliases'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='migration.hint.link'
|
||||
defaultMessage='create an account alias'
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage(messages.acctFieldLabel)}
|
||||
>
|
||||
<Input
|
||||
name='targetAccount'
|
||||
placeholder={intl.formatMessage(messages.acctFieldPlaceholder)}
|
||||
onChange={this.handleInputChange}
|
||||
value={this.state.targetAccount}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage(messages.currentPasswordFieldLabel)}
|
||||
>
|
||||
<Input
|
||||
type='password'
|
||||
name='password'
|
||||
onChange={this.handleInputChange}
|
||||
value={this.state.password}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormActions>
|
||||
<Button
|
||||
theme='primary'
|
||||
text={intl.formatMessage(messages.submit)}
|
||||
onClick={this.handleSubmit}
|
||||
/>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { moveAccount } from 'soapbox/actions/security';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { Button, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.migration', defaultMessage: 'Account migration' },
|
||||
submit: { id: 'migration.submit', defaultMessage: 'Move followers' },
|
||||
moveAccountSuccess: { id: 'migration.move_account.success', defaultMessage: 'Account successfully moved.' },
|
||||
moveAccountFail: { id: 'migration.move_account.fail', defaultMessage: 'Account migration failed.' },
|
||||
moveAccountFailCooldownPeriod: { id: 'migration.move_account.fail.cooldown_period', defaultMessage: 'You have moved your account too recently. Please try again later.' },
|
||||
acctFieldLabel: { id: 'migration.fields.acct.label', defaultMessage: 'Handle of the new account' },
|
||||
acctFieldPlaceholder: { id: 'migration.fields.acct.placeholder', defaultMessage: 'username@domain' },
|
||||
currentPasswordFieldLabel: { id: 'migration.fields.confirm_password.label', defaultMessage: 'Current password' },
|
||||
});
|
||||
|
||||
const Migration = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const cooldownPeriod = useAppSelector((state) => state.instance.pleroma.getIn(['metadata', 'migration_cooldown_period'])) as number | undefined;
|
||||
|
||||
const [targetAccount, setTargetAccount] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||
if (e.target.name === 'password') setPassword(e.target.value);
|
||||
else setTargetAccount(e.target.value);
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
setTargetAccount('');
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
const handleSubmit: React.FormEventHandler = e => {
|
||||
setIsLoading(true);
|
||||
return dispatch(moveAccount(targetAccount, password)).then(() => {
|
||||
clearForm();
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.moveAccountSuccess)));
|
||||
}).catch(error => {
|
||||
let message = intl.formatMessage(messages.moveAccountFail);
|
||||
|
||||
const errorMessage = (error.response?.data)?.error;
|
||||
if (errorMessage === 'You are within cooldown period.') {
|
||||
message = intl.formatMessage(messages.moveAccountFailCooldownPeriod);
|
||||
}
|
||||
|
||||
dispatch(snackbar.error(message));
|
||||
}).then(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='migration.hint'
|
||||
defaultMessage='This will move your followers to the new account. No other data will be moved. To perform migration, you need to {link} on your new account first.'
|
||||
values={{
|
||||
link: (
|
||||
<Link
|
||||
className='hover:underline text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-500'
|
||||
to='/settings/aliases'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='migration.hint.link'
|
||||
defaultMessage='create an account alias'
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{!!cooldownPeriod && (<>
|
||||
{' '}
|
||||
<FormattedMessage
|
||||
id='migration.hint.cooldown_period'
|
||||
defaultMessage='If you migrate your account, you will not be able to migrate your account for {cooldownPeriod, plural, one {one day} other {the next # days}}.'
|
||||
values={{ cooldownPeriod }}
|
||||
/>
|
||||
</>)}
|
||||
</Text>
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage(messages.acctFieldLabel)}
|
||||
>
|
||||
<Input
|
||||
name='targetAccount'
|
||||
placeholder={intl.formatMessage(messages.acctFieldPlaceholder)}
|
||||
onChange={handleInputChange}
|
||||
value={targetAccount}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage(messages.currentPasswordFieldLabel)}
|
||||
>
|
||||
<Input
|
||||
type='password'
|
||||
name='password'
|
||||
onChange={handleInputChange}
|
||||
value={password}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormActions>
|
||||
<Button
|
||||
theme='primary'
|
||||
text={intl.formatMessage(messages.submit)}
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Migration;
|
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
import { HStack, Stack } from 'soapbox/components/ui';
|
||||
|
||||
import { randomIntFromInterval, generateText } from '../utils';
|
||||
|
||||
export default ({ limit }: { limit: number }) => {
|
||||
const length = randomIntFromInterval(15, 3);
|
||||
const acctLength = randomIntFromInterval(15, 3);
|
||||
|
||||
return (
|
||||
<>
|
||||
{new Array(limit).fill(undefined).map((_, idx) => (
|
||||
<HStack key={idx} alignItems='center' space={2} className='animate-pulse'>
|
||||
<Stack space={3} className='text-center'>
|
||||
<div
|
||||
className='w-9 h-9 block mx-auto rounded-full bg-primary-200 dark:bg-primary-700'
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack className='text-primary-200 dark:text-primary-700'>
|
||||
<p>{generateText(length)}</p>
|
||||
<p>{generateText(acctLength)}</p>
|
||||
</Stack>
|
||||
</HStack>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,123 +1,201 @@
|
||||
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '../../../../jest/test-helpers';
|
||||
import { normalizeAccount } from '../../../../normalizers';
|
||||
import { __stub } from 'soapbox/api';
|
||||
|
||||
import { render, rootState, screen, waitFor } from '../../../../jest/test-helpers';
|
||||
import { normalizeInstance } from '../../../../normalizers';
|
||||
import WhoToFollowPanel from '../who-to-follow-panel';
|
||||
|
||||
describe('<WhoToFollow />', () => {
|
||||
it('renders suggested accounts', () => {
|
||||
const store = {
|
||||
accounts: ImmutableMap({
|
||||
'1': normalizeAccount({
|
||||
id: '1',
|
||||
acct: 'username',
|
||||
display_name: 'My name',
|
||||
avatar: 'test.jpg',
|
||||
}),
|
||||
}),
|
||||
suggestions: {
|
||||
items: ImmutableOrderedSet([{
|
||||
const buildTruthSuggestion = (id: string) => ({
|
||||
account_avatar: 'avatar',
|
||||
account_id: id,
|
||||
acct: 'acct',
|
||||
display_name: 'my name',
|
||||
note: 'hello',
|
||||
verified: true,
|
||||
});
|
||||
|
||||
const buildSuggestion = (id: string) => ({
|
||||
source: 'staff',
|
||||
account: '1',
|
||||
}]),
|
||||
account: {
|
||||
username: 'username',
|
||||
verified: true,
|
||||
id,
|
||||
acct: 'acct',
|
||||
avatar: 'avatar',
|
||||
avatar_static: 'avatar',
|
||||
display_name: 'my name',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('<WhoToFollow />', () => {
|
||||
let store: any;
|
||||
|
||||
describe('using Truth Social software', () => {
|
||||
beforeEach(() => {
|
||||
store = rootState
|
||||
.set('me', '1234')
|
||||
.set('instance', normalizeInstance({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
|
||||
}));
|
||||
});
|
||||
|
||||
describe('with a single suggestion', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/truth/carousels/suggestions')
|
||||
.reply(200, [buildTruthSuggestion('1')], {
|
||||
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders suggested accounts', async () => {
|
||||
render(<WhoToFollowPanel limit={1} />, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders multiple accounts', () => {
|
||||
const store = {
|
||||
accounts: ImmutableMap({
|
||||
'1': normalizeAccount({
|
||||
id: '1',
|
||||
acct: 'username',
|
||||
display_name: 'My name',
|
||||
avatar: 'test.jpg',
|
||||
}),
|
||||
'2': normalizeAccount({
|
||||
id: '1',
|
||||
acct: 'username2',
|
||||
display_name: 'My other name',
|
||||
avatar: 'test.jpg',
|
||||
}),
|
||||
}),
|
||||
suggestions: {
|
||||
items: ImmutableOrderedSet([
|
||||
{
|
||||
source: 'staff',
|
||||
account: '1',
|
||||
},
|
||||
{
|
||||
source: 'staff',
|
||||
account: '2',
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
describe('with a multiple suggestion', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/truth/carousels/suggestions')
|
||||
.reply(200, [buildTruthSuggestion('1'), buildTruthSuggestion('2')], {
|
||||
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders suggested accounts', async () => {
|
||||
render(<WhoToFollowPanel limit={2} />, undefined, store);
|
||||
|
||||
render(<WhoToFollowPanel limit={3} />, undefined, store);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('account')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('respects the limit prop', () => {
|
||||
const store = {
|
||||
accounts: ImmutableMap({
|
||||
'1': normalizeAccount({
|
||||
id: '1',
|
||||
acct: 'username',
|
||||
display_name: 'My name',
|
||||
avatar: 'test.jpg',
|
||||
}),
|
||||
'2': normalizeAccount({
|
||||
id: '1',
|
||||
acct: 'username2',
|
||||
display_name: 'My other name',
|
||||
avatar: 'test.jpg',
|
||||
}),
|
||||
}),
|
||||
suggestions: {
|
||||
items: ImmutableOrderedSet([
|
||||
{
|
||||
source: 'staff',
|
||||
account: '1',
|
||||
},
|
||||
{
|
||||
source: 'staff',
|
||||
account: '2',
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
describe('with a set limit', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/truth/carousels/suggestions')
|
||||
.reply(200, [buildTruthSuggestion('1'), buildTruthSuggestion('2')], {
|
||||
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('respects the limit prop', async () => {
|
||||
render(<WhoToFollowPanel limit={1} />, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('account')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders empty', () => {
|
||||
const store = {
|
||||
accounts: ImmutableMap({
|
||||
'1': normalizeAccount({
|
||||
id: '1',
|
||||
acct: 'username',
|
||||
display_name: 'My name',
|
||||
avatar: 'test.jpg',
|
||||
}),
|
||||
'2': normalizeAccount({
|
||||
id: '1',
|
||||
acct: 'username2',
|
||||
display_name: 'My other name',
|
||||
avatar: 'test.jpg',
|
||||
}),
|
||||
}),
|
||||
suggestions: {
|
||||
items: ImmutableOrderedSet([]),
|
||||
},
|
||||
};
|
||||
describe('when the API returns an empty list', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/truth/carousels/suggestions')
|
||||
.reply(200, [], {
|
||||
link: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders empty', async () => {
|
||||
render(<WhoToFollowPanel limit={1} />, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('account')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('using Pleroma software', () => {
|
||||
beforeEach(() => {
|
||||
store = rootState.set('me', '1234');
|
||||
});
|
||||
|
||||
describe('with a single suggestion', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v2/suggestions')
|
||||
.reply(200, [buildSuggestion('1')], {
|
||||
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders suggested accounts', async () => {
|
||||
render(<WhoToFollowPanel limit={1} />, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a multiple suggestion', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v2/suggestions')
|
||||
.reply(200, [buildSuggestion('1'), buildSuggestion('2')], {
|
||||
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders suggested accounts', async () => {
|
||||
render(<WhoToFollowPanel limit={2} />, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('account')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a set limit', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v2/suggestions')
|
||||
.reply(200, [buildSuggestion('1'), buildSuggestion('2')], {
|
||||
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('respects the limit prop', async () => {
|
||||
render(<WhoToFollowPanel limit={1} />, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('account')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the API returns an empty list', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v2/suggestions')
|
||||
.reply(200, [], {
|
||||
link: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders empty', async () => {
|
||||
render(<WhoToFollowPanel limit={1} />, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('account')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,13 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
/** Get the last version of this value. */
|
||||
// https://usehooks.com/usePrevious/
|
||||
export const usePrevious = <T>(value: T): T | undefined => {
|
||||
const ref = useRef<T>();
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
|
||||
return ref.current;
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
import { normalizeUsername } from '../input';
|
||||
|
||||
test('normalizeUsername', () => {
|
||||
expect(normalizeUsername('@alex')).toBe('alex');
|
||||
expect(normalizeUsername('alex@alexgleason.me')).toBe('alex@alexgleason.me');
|
||||
expect(normalizeUsername('@alex@gleasonator.com')).toBe('alex@gleasonator.com');
|
||||
});
|
@ -0,0 +1,13 @@
|
||||
/** Trim the username and strip the leading @. */
|
||||
const normalizeUsername = (username: string): string => {
|
||||
const trimmed = username.trim();
|
||||
if (trimmed[0] === '@') {
|
||||
return trimmed.slice(1);
|
||||
} else {
|
||||
return trimmed;
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
normalizeUsername,
|
||||
};
|
@ -0,0 +1,61 @@
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
|
||||
import type { InfiniteData, QueryKey, UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
result: T[],
|
||||
hasMore: boolean,
|
||||
link?: string,
|
||||
}
|
||||
|
||||
/** Flatten paginated results into a single array. */
|
||||
const flattenPages = <T>(queryInfo: UseInfiniteQueryResult<PaginatedResult<T>>) => {
|
||||
return queryInfo.data?.pages.reduce<T[]>(
|
||||
(prev: T[], curr) => [...prev, ...curr.result],
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
/** Traverse pages and update the item inside if found. */
|
||||
const updatePageItem = <T>(queryKey: QueryKey, newItem: T, isItem: (item: T, newItem: T) => boolean) => {
|
||||
queryClient.setQueriesData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
|
||||
if (data) {
|
||||
const pages = data.pages.map(page => {
|
||||
const result = page.result.map(item => isItem(item, newItem) ? newItem : item);
|
||||
return { ...page, result };
|
||||
});
|
||||
return { ...data, pages };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Insert the new item at the beginning of the first page. */
|
||||
const appendPageItem = <T>(queryKey: QueryKey, newItem: T) => {
|
||||
queryClient.setQueryData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
|
||||
if (data) {
|
||||
const pages = [...data.pages];
|
||||
pages[0] = { ...pages[0], result: [...pages[0].result, newItem] };
|
||||
return { ...data, pages };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Remove an item inside if found. */
|
||||
const removePageItem = <T>(queryKey: QueryKey, itemToRemove: T, isItem: (item: T, newItem: T) => boolean) => {
|
||||
queryClient.setQueriesData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
|
||||
if (data) {
|
||||
const pages = data.pages.map(page => {
|
||||
const result = page.result.filter(item => !isItem(item, itemToRemove));
|
||||
return { ...page, result };
|
||||
});
|
||||
return { ...data, pages };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
flattenPages,
|
||||
updatePageItem,
|
||||
appendPageItem,
|
||||
removePageItem,
|
||||
};
|
Loading…
Reference in new issue