Merge remote-tracking branch 'origin/develop' into scroll-position
This commit is contained in:
commit
7b1190e6cd
|
@ -1,4 +1,4 @@
|
|||
image: node:16
|
||||
image: node:18
|
||||
|
||||
variables:
|
||||
NODE_ENV: test
|
||||
|
|
|
@ -1 +1 @@
|
|||
nodejs 16.14.2
|
||||
nodejs 18.2.0
|
||||
|
|
|
@ -5,7 +5,7 @@ import { mockStore } from 'soapbox/jest/test-helpers';
|
|||
import rootReducer from 'soapbox/reducers';
|
||||
|
||||
import { normalizeAccount } from '../../normalizers';
|
||||
import { createAccount, fetchAccount } from '../accounts';
|
||||
import { createAccount, fetchAccount, fetchAccountByUsername } from '../accounts';
|
||||
|
||||
let store;
|
||||
|
||||
|
@ -130,3 +130,230 @@ describe('fetchAccount()', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAccountByUsername()', () => {
|
||||
const id = '123';
|
||||
const username = 'tiger';
|
||||
let state, account;
|
||||
|
||||
describe('when the account has already been cached in redux', () => {
|
||||
beforeEach(() => {
|
||||
account = normalizeAccount({
|
||||
id,
|
||||
acct: username,
|
||||
display_name: 'Tiger',
|
||||
avatar: 'test.jpg',
|
||||
birthday: undefined,
|
||||
});
|
||||
|
||||
state = rootReducer(undefined, {})
|
||||
.set('accounts', ImmutableMap({
|
||||
[id]: account,
|
||||
}));
|
||||
|
||||
store = mockStore(state);
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/accounts/${id}`).reply(200, account);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null', async() => {
|
||||
const result = await store.dispatch(fetchAccountByUsername(username));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when "accountByUsername" feature is enabled', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {})
|
||||
.set('instance', {
|
||||
version: '2.7.2 (compatible; Pleroma 2.4.52-1337-g4779199e.gleasonator+soapbox)',
|
||||
pleroma: ImmutableMap({
|
||||
metadata: ImmutableMap({
|
||||
features: [],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.set('me', '123');
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/accounts/${username}`).reply(200, account);
|
||||
mock.onGet(`/api/v1/accounts/relationships?${[account.id].map(id => `id[]=${id}`).join('&')}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dispatch the proper actions', async() => {
|
||||
await store.dispatch(fetchAccountByUsername(username));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions[0]).toEqual({
|
||||
type: 'RELATIONSHIPS_FETCH_REQUEST',
|
||||
ids: ['123'],
|
||||
skipLoading: true,
|
||||
});
|
||||
expect(actions[1].type).toEqual('ACCOUNTS_IMPORT');
|
||||
expect(actions[2].type).toEqual('ACCOUNT_FETCH_SUCCESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/accounts/${username}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dispatch the proper actions', async() => {
|
||||
const expectedActions = [
|
||||
{
|
||||
type: 'ACCOUNT_FETCH_FAIL',
|
||||
id: null,
|
||||
error: new Error('Network Error'),
|
||||
skipAlert: true,
|
||||
},
|
||||
{ type: 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP', username: 'tiger' },
|
||||
];
|
||||
|
||||
await store.dispatch(fetchAccountByUsername(username));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when "accountLookup" feature is enabled', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {})
|
||||
.set('instance', {
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
|
||||
pleroma: ImmutableMap({
|
||||
metadata: ImmutableMap({
|
||||
features: [],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.set('me', '123');
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/accounts/lookup').reply(200, account);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dispatch the proper actions', async() => {
|
||||
await store.dispatch(fetchAccountByUsername(username));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions[0]).toEqual({
|
||||
type: 'ACCOUNT_LOOKUP_REQUEST',
|
||||
acct: username,
|
||||
});
|
||||
expect(actions[1].type).toEqual('ACCOUNTS_IMPORT');
|
||||
expect(actions[2].type).toEqual('ACCOUNT_LOOKUP_SUCCESS');
|
||||
expect(actions[3].type).toEqual('ACCOUNT_FETCH_SUCCESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/accounts/lookup').networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dispatch the proper actions', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'ACCOUNT_LOOKUP_REQUEST', acct: 'tiger' },
|
||||
{ type: 'ACCOUNT_LOOKUP_FAIL' },
|
||||
{
|
||||
type: 'ACCOUNT_FETCH_FAIL',
|
||||
id: null,
|
||||
error: new Error('Network Error'),
|
||||
skipAlert: true,
|
||||
},
|
||||
{ type: 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP', username },
|
||||
];
|
||||
|
||||
await store.dispatch(fetchAccountByUsername(username));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using the accountSearch function', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootReducer(undefined, {}).set('me', '123');
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/accounts/search').reply(200, [account]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dispatch the proper actions', async() => {
|
||||
await store.dispatch(fetchAccountByUsername(username));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions[0]).toEqual({
|
||||
type: 'ACCOUNT_SEARCH_REQUEST',
|
||||
params: { q: username, limit: 5, resolve: true },
|
||||
});
|
||||
expect(actions[1].type).toEqual('ACCOUNTS_IMPORT');
|
||||
expect(actions[2].type).toEqual('ACCOUNT_SEARCH_SUCCESS');
|
||||
expect(actions[3]).toEqual({
|
||||
type: 'RELATIONSHIPS_FETCH_REQUEST',
|
||||
ids: [ '123' ],
|
||||
skipLoading: true,
|
||||
});
|
||||
expect(actions[4].type).toEqual('ACCOUNT_FETCH_SUCCESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/accounts/search').networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dispatch the proper actions', async() => {
|
||||
const expectedActions = [
|
||||
{
|
||||
type: 'ACCOUNT_SEARCH_REQUEST',
|
||||
params: { q: username, limit: 5, resolve: true },
|
||||
},
|
||||
{ type: 'ACCOUNT_SEARCH_FAIL', skipAlert: true },
|
||||
{
|
||||
type: 'ACCOUNT_FETCH_FAIL',
|
||||
id: null,
|
||||
error: new Error('Network Error'),
|
||||
skipAlert: true,
|
||||
},
|
||||
{ type: 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP', username },
|
||||
];
|
||||
|
||||
await store.dispatch(fetchAccountByUsername(username));
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -160,7 +160,7 @@ export function fetchAccountByUsername(username) {
|
|||
|
||||
if (account) {
|
||||
dispatch(fetchAccount(account.get('id')));
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const instance = state.get('instance');
|
||||
|
@ -168,7 +168,7 @@ export function fetchAccountByUsername(username) {
|
|||
const me = state.get('me');
|
||||
|
||||
if (features.accountByUsername && (me || !features.accountLookup)) {
|
||||
api(getState).get(`/api/v1/accounts/${username}`).then(response => {
|
||||
return api(getState).get(`/api/v1/accounts/${username}`).then(response => {
|
||||
dispatch(fetchRelationships([response.data.id]));
|
||||
dispatch(importFetchedAccount(response.data));
|
||||
dispatch(fetchAccountSuccess(response.data));
|
||||
|
@ -177,14 +177,14 @@ export function fetchAccountByUsername(username) {
|
|||
dispatch(importErrorWhileFetchingAccountByUsername(username));
|
||||
});
|
||||
} else if (features.accountLookup) {
|
||||
dispatch(accountLookup(username)).then(account => {
|
||||
return dispatch(accountLookup(username)).then(account => {
|
||||
dispatch(fetchAccountSuccess(account));
|
||||
}).catch(error => {
|
||||
dispatch(fetchAccountFail(null, error));
|
||||
dispatch(importErrorWhileFetchingAccountByUsername(username));
|
||||
});
|
||||
} else {
|
||||
dispatch(accountSearch({
|
||||
return dispatch(accountSearch({
|
||||
q: username,
|
||||
limit: 5,
|
||||
resolve: true,
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display-name';
|
||||
import { makeGetAccount } from '../../../selectors';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
|
||||
export default @connect(makeMapStateToProps)
|
||||
@injectIntl
|
||||
class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { account } = this.props;
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
|
||||
import DisplayName from 'soapbox/components/display-name';
|
||||
import { Avatar } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
interface IAccount {
|
||||
accountId: string,
|
||||
}
|
||||
|
||||
const Account: React.FC<IAccount> = ({ accountId }) => {
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar src={account.avatar} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
|
@ -1,70 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { removeFromListAdder, addToListAdder } from 'soapbox/actions/lists';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
||||
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
|
||||
});
|
||||
|
||||
const MapStateToProps = (state, { listId, added }) => ({
|
||||
list: state.get('lists').get(listId),
|
||||
added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { listId }) => ({
|
||||
onRemove: () => dispatch(removeFromListAdder(listId)),
|
||||
onAdd: () => dispatch(addToListAdder(listId)),
|
||||
});
|
||||
|
||||
export default @connect(MapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class List extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
list: ImmutablePropTypes.map.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onRemove: PropTypes.func.isRequired,
|
||||
onAdd: PropTypes.func.isRequired,
|
||||
added: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
added: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { list, intl, onRemove, onAdd, added } = this.props;
|
||||
|
||||
let button;
|
||||
|
||||
if (added) {
|
||||
button = <IconButton src={require('@tabler/icons/icons/x.svg')} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
|
||||
} else {
|
||||
button = <IconButton src={require('@tabler/icons/icons/plus.svg')} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='list'>
|
||||
<div className='list__wrapper'>
|
||||
<div className='list__display-name'>
|
||||
<Icon id='list-ul' className='column-link__icon' fixedWidth />
|
||||
{list.get('title')}
|
||||
</div>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { removeFromListAdder, addToListAdder } from 'soapbox/actions/lists';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
||||
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
|
||||
});
|
||||
|
||||
interface IList {
|
||||
listId: string,
|
||||
}
|
||||
|
||||
const List: React.FC<IList> = ({ listId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const list = useAppSelector((state) => state.lists.get(listId));
|
||||
const added = useAppSelector((state) => state.listAdder.lists.items.includes(listId));
|
||||
|
||||
const onRemove = () => dispatch(removeFromListAdder(listId));
|
||||
const onAdd = () => dispatch(addToListAdder(listId));
|
||||
|
||||
if (!list) return null;
|
||||
|
||||
let button;
|
||||
|
||||
if (added) {
|
||||
button = <IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/icons/x.svg')} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
|
||||
} else {
|
||||
button = <IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/icons/plus.svg')} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1.5 px-2 py-4 text-black dark:text-white'>
|
||||
<Icon src={require('@tabler/icons/icons/list.svg')} fixedWidth />
|
||||
<span className='flex-grow'>
|
||||
{list.title}
|
||||
</span>
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default List;
|
|
@ -1,105 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { setupListAdder, resetListAdder } from 'soapbox/actions/lists';
|
||||
import { CardHeader, CardTitle, Modal } from 'soapbox/components/ui';
|
||||
|
||||
import NewListForm from '../lists/components/new_list_form';
|
||||
|
||||
import Account from './components/account';
|
||||
import List from './components/list';
|
||||
|
||||
// hack
|
||||
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
|
||||
if (!lists) {
|
||||
return lists;
|
||||
}
|
||||
|
||||
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
listIds: getOrderedLists(state).map(list=>list.get('id')),
|
||||
account: state.getIn(['accounts', accountId]),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onInitialize: accountId => dispatch(setupListAdder(accountId)),
|
||||
onReset: () => dispatch(resetListAdder()),
|
||||
});
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
|
||||
add: { id: 'lists.new.create', defaultMessage: 'Add List' },
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class ListAdder extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
accountId: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onInitialize: PropTypes.func.isRequired,
|
||||
onReset: PropTypes.func.isRequired,
|
||||
listIds: ImmutablePropTypes.list.isRequired,
|
||||
account: ImmutablePropTypes.record,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { onInitialize, accountId } = this.props;
|
||||
onInitialize(accountId);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { onReset } = this.props;
|
||||
onReset();
|
||||
}
|
||||
|
||||
onClickClose = () => {
|
||||
this.props.onClose('LIST_ADDER');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { accountId, listIds, intl } = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<FormattedMessage id='list_adder.header_title' defaultMessage='Add or Remove from Lists' />}
|
||||
onClose={this.onClickClose}
|
||||
>
|
||||
<div className='compose-modal__content'>
|
||||
<div className='list-adder'>
|
||||
<div className='list-adder__account'>
|
||||
<Account accountId={accountId} />
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.add)} />
|
||||
</CardHeader>
|
||||
<NewListForm />
|
||||
|
||||
<br />
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.subheading)} />
|
||||
</CardHeader>
|
||||
<div className='list-adder__lists'>
|
||||
{listIds.map(ListId => <List key={ListId} listId={ListId} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { setupListAdder, resetListAdder } from 'soapbox/actions/lists';
|
||||
import { CardHeader, CardTitle, Modal } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import NewListForm from '../lists/components/new_list_form';
|
||||
|
||||
import Account from './components/account';
|
||||
import List from './components/list';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { List as ListEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
|
||||
add: { id: 'lists.new.create', defaultMessage: 'Add List' },
|
||||
});
|
||||
|
||||
// hack
|
||||
const getOrderedLists = createSelector([(state: RootState) => state.lists], lists => {
|
||||
if (!lists) {
|
||||
return lists;
|
||||
}
|
||||
|
||||
return lists.toList().filter(item => !!item).sort((a, b) => (a as ListEntity).title.localeCompare((b as ListEntity).title)) as ImmutableList<ListEntity>;
|
||||
});
|
||||
|
||||
interface IListAdder {
|
||||
accountId: string,
|
||||
onClose: (type: string) => void,
|
||||
}
|
||||
|
||||
const ListAdder: React.FC<IListAdder> = ({ accountId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const listIds = useAppSelector((state) => getOrderedLists(state).map(list => list.id));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setupListAdder(accountId));
|
||||
|
||||
return () => {
|
||||
dispatch(resetListAdder());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onClickClose = () => {
|
||||
onClose('LIST_ADDER');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<FormattedMessage id='list_adder.header_title' defaultMessage='Add or Remove from Lists' />}
|
||||
onClose={onClickClose}
|
||||
>
|
||||
<Account accountId={accountId} />
|
||||
|
||||
<br />
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.add)} />
|
||||
</CardHeader>
|
||||
<NewListForm />
|
||||
|
||||
<br />
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.subheading)} />
|
||||
</CardHeader>
|
||||
<div>
|
||||
{listIds.map(ListId => <List key={ListId} listId={ListId} />)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListAdder;
|
|
@ -1,78 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { removeFromListEditor, addToListEditor } from '../../../actions/lists';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display-name';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { makeGetAccount } from '../../../selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
||||
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId, added }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
||||
onRemove: () => dispatch(removeFromListEditor(accountId)),
|
||||
onAdd: () => dispatch(addToListEditor(accountId)),
|
||||
});
|
||||
|
||||
export default @connect(makeMapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onRemove: PropTypes.func.isRequired,
|
||||
onAdd: PropTypes.func.isRequired,
|
||||
added: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
added: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { account, intl, onRemove, onAdd, added } = this.props;
|
||||
|
||||
let button;
|
||||
|
||||
if (added) {
|
||||
button = <IconButton src={require('@tabler/icons/icons/x.svg')} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
|
||||
} else {
|
||||
button = <IconButton src={require('@tabler/icons/icons/plus.svg')} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { removeFromListEditor, addToListEditor } from 'soapbox/actions/lists';
|
||||
import DisplayName from 'soapbox/components/display-name';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { Avatar } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
||||
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
|
||||
});
|
||||
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
interface IAccount {
|
||||
accountId: string,
|
||||
}
|
||||
|
||||
const Account: React.FC<IAccount> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
const isAdded = useAppSelector((state) => state.listEditor.accounts.items.includes(accountId));
|
||||
|
||||
const onRemove = () => dispatch(removeFromListEditor(accountId));
|
||||
const onAdd = () => dispatch(addToListEditor(accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
let button;
|
||||
|
||||
if (isAdded) {
|
||||
button = <IconButton src={require('@tabler/icons/icons/x.svg')} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
|
||||
} else {
|
||||
button = <IconButton src={require('@tabler/icons/icons/plus.svg')} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar src={account.avatar} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
|
@ -1,73 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { changeListEditorTitle, submitListEditor } from '../../../actions/lists';
|
||||
import { Button } from '../../../components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
|
||||
save: { id: 'lists.new.save_title', defaultMessage: 'Save Title' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['listEditor', 'title']),
|
||||
disabled: !state.getIn(['listEditor', 'isChanged']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onChange: value => dispatch(changeListEditorTitle(value)),
|
||||
onSubmit: () => dispatch(submitListEditor(false)),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class ListForm extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleChange = e => {
|
||||
this.props.onChange(e.target.value);
|
||||
}
|
||||
|
||||
handleSubmit = e => {
|
||||
e.preventDefault();
|
||||
this.props.onSubmit();
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
this.props.onSubmit();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, disabled, intl } = this.props;
|
||||
const save = intl.formatMessage(messages.save);
|
||||
|
||||
return (
|
||||
<form className='column-inline-form' method='post' onSubmit={this.handleSubmit}>
|
||||
<input
|
||||
className='setting-text new-list-form__input'
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
|
||||
{ !disabled &&
|
||||
<Button
|
||||
className='new-list-form__btn'
|
||||
onClick={this.handleClick}
|
||||
>
|
||||
{save}
|
||||
</Button>
|
||||
}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { changeListEditorTitle, submitListEditor } from 'soapbox/actions/lists';
|
||||
import { Button, Form, HStack, Input } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
|
||||
save: { id: 'lists.new.save_title', defaultMessage: 'Save Title' },
|
||||
});
|
||||
|
||||
const ListForm = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const value = useAppSelector((state) => state.listEditor.title);
|
||||
const disabled = useAppSelector((state) => !state.listEditor.isChanged);
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||
dispatch(changeListEditorTitle(e.target.value));
|
||||
};
|
||||
|
||||
const handleSubmit: React.FormEventHandler<Element> = e => {
|
||||
e.preventDefault();
|
||||
dispatch(submitListEditor(false));
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
dispatch(submitListEditor(false));
|
||||
};
|
||||
|
||||
const save = intl.formatMessage(messages.save);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<HStack space={2}>
|
||||
<Input
|
||||
outerClassName='flex-grow'
|
||||
type='text'
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Button onClick={handleClick} disabled={disabled}>
|
||||
{save}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListForm;
|
|
@ -1,83 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from 'soapbox/actions/lists';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Button } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
|
||||
searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Search' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['listEditor', 'suggestions', 'value']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSubmit: value => dispatch(fetchListSuggestions(value)),
|
||||
onClear: () => dispatch(clearListSuggestions()),
|
||||
onChange: value => dispatch(changeListSuggestions(value)),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class Search extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleChange = e => {
|
||||
this.props.onChange(e.target.value);
|
||||
}
|
||||
|
||||
handleKeyUp = e => {
|
||||
if (e.keyCode === 13) {
|
||||
this.props.onSubmit(this.props.value);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit = () => {
|
||||
this.props.onSubmit(this.props.value);
|
||||
}
|
||||
|
||||
handleClear = () => {
|
||||
this.props.onClear();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, intl } = this.props;
|
||||
const hasValue = value.length > 0;
|
||||
|
||||
return (
|
||||
<div className='list-editor__search search'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
|
||||
|
||||
<input
|
||||
className='search__input'
|
||||
type='text'
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
onKeyUp={this.handleKeyUp}
|
||||
placeholder={intl.formatMessage(messages.search)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
|
||||
<Icon src={require('@tabler/icons/icons/backspace.svg')} aria-label={intl.formatMessage(messages.search)} className={classNames('svg-icon--backspace', { active: hasValue })} />
|
||||
</div>
|
||||
<Button onClick={this.handleSubmit}>{intl.formatMessage(messages.searchTitle)}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from 'soapbox/actions/lists';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Button, Form, HStack, Input } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
|
||||
searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Search' },
|
||||
});
|
||||
|
||||
const Search = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const value = useAppSelector((state) => state.listEditor.suggestions.value);
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||
dispatch(changeListSuggestions(e.target.value));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(fetchListSuggestions(value));
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
dispatch(clearListSuggestions());
|
||||
};
|
||||
|
||||
const hasValue = value.length > 0;
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<HStack space={2}>
|
||||
<label className='flex-grow relative'>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
|
||||
|
||||
<Input
|
||||
type='text'
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={intl.formatMessage(messages.search)}
|
||||
/>
|
||||
<div role='button' tabIndex={0} className='search__icon' onClick={handleClear}>
|
||||
<Icon src={require('@tabler/icons/icons/backspace.svg')} aria-label={intl.formatMessage(messages.search)} className={classNames('svg-icon--backspace', { active: hasValue })} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<Button onClick={handleSubmit}>{intl.formatMessage(messages.searchTitle)}</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
|
@ -1,105 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { setupListEditor, clearListSuggestions, resetListEditor } from 'soapbox/actions/lists';
|
||||
import { CardHeader, CardTitle, Modal } from 'soapbox/components/ui';
|
||||
|
||||
import Account from './components/account';
|
||||
import EditListForm from './components/edit_list_form';
|
||||
import Search from './components/search';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
accountIds: state.getIn(['listEditor', 'accounts', 'items']),
|
||||
searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onInitialize: listId => dispatch(setupListEditor(listId)),
|
||||
onClear: () => dispatch(clearListSuggestions()),
|
||||
onReset: () => dispatch(resetListEditor()),
|
||||
});
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
changeTitle: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
|
||||
addToList: { id: 'lists.account.add', defaultMessage: 'Add to list' },
|
||||
removeFromList: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
||||
editList: { id: 'lists.edit', defaultMessage: 'Edit list' },
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class ListEditor extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
listId: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onInitialize: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
onReset: PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list.isRequired,
|
||||
searchAccountIds: ImmutablePropTypes.list.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { onInitialize, listId } = this.props;
|
||||
onInitialize(listId);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { onReset } = this.props;
|
||||
onReset();
|
||||
}
|
||||
|
||||
onClickClose = () => {
|
||||
this.props.onClose('LIST_ADDER');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { accountIds, searchAccountIds, intl } = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<FormattedMessage id='lists.edit' defaultMessage='Edit list' />}
|
||||
onClose={this.onClickClose}
|
||||
>
|
||||
<div className='compose-modal__content list-editor__content'>
|
||||
<div className='list-editor'>
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.changeTitle)} />
|
||||
</CardHeader>
|
||||
<EditListForm />
|
||||
<br />
|
||||
|
||||
{
|
||||
accountIds.size > 0 &&
|
||||
<div>
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.removeFromList)} />
|
||||
</CardHeader>
|
||||
<div className='list-editor__accounts'>
|
||||
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<br />
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.addToList)} />
|
||||
</CardHeader>
|
||||
<Search />
|
||||
<div className='list-editor__accounts'>
|
||||
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { setupListEditor, resetListEditor } from 'soapbox/actions/lists';
|
||||
import { CardHeader, CardTitle, Modal } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import Account from './components/account';
|
||||
import EditListForm from './components/edit_list_form';
|
||||
import Search from './components/search';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
changeTitle: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
|
||||
addToList: { id: 'lists.account.add', defaultMessage: 'Add to list' },
|
||||
removeFromList: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
||||
editList: { id: 'lists.edit', defaultMessage: 'Edit list' },
|
||||
});
|
||||
|
||||
interface IListEditor {
|
||||
listId: string,
|
||||
onClose: (type: string) => void,
|
||||
}
|
||||
|
||||
const ListEditor: React.FC<IListEditor> = ({ listId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const accountIds = useAppSelector((state) => state.listEditor.accounts.items);
|
||||
const searchAccountIds = useAppSelector((state) => state.listEditor.suggestions.items);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setupListEditor(listId));
|
||||
|
||||
return () => {
|
||||
dispatch(resetListEditor());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onClickClose = () => {
|
||||
onClose('LIST_ADDER');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<FormattedMessage id='lists.edit' defaultMessage='Edit list' />}
|
||||
onClose={onClickClose}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.changeTitle)} />
|
||||
</CardHeader>
|
||||
<EditListForm />
|
||||
<br />
|
||||
|
||||
{accountIds.size > 0 && (
|
||||
<div>
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.removeFromList)} />
|
||||
</CardHeader>
|
||||
<div className='max-h-48 overflow-y-auto'>
|
||||
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<br />
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.addToList)} />
|
||||
</CardHeader>
|
||||
<Search />
|
||||
<div className='max-h-48 overflow-y-auto'>
|
||||
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListEditor;
|
|
@ -65,7 +65,7 @@ const ListTimeline: React.FC = () => {
|
|||
// }));
|
||||
// };
|
||||
|
||||
const title = list ? list.get('title') : id;
|
||||
const title = list ? list.title : id;
|
||||
|
||||
if (typeof list === 'undefined') {
|
||||
return (
|
||||
|
|
|
@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { changeListEditorTitle, submitListEditor } from 'soapbox/actions/lists';
|
||||
import { Button } from 'soapbox/components/ui';
|
||||
import { Button, Form, HStack, Input } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -23,7 +23,7 @@ const NewListForm: React.FC = () => {
|
|||
dispatch(changeListEditorTitle(e.target.value));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement | HTMLButtonElement>) => {
|
||||
const handleSubmit = (e: React.FormEvent<Element>) => {
|
||||
e.preventDefault();
|
||||
dispatch(submitListEditor(true));
|
||||
};
|
||||
|
@ -32,27 +32,28 @@ const NewListForm: React.FC = () => {
|
|||
const create = intl.formatMessage(messages.create);
|
||||
|
||||
return (
|
||||
<form className='column-inline-form' method='post' onSubmit={handleSubmit}>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{label}</span>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<HStack space={2}>
|
||||
<label className='flex-grow'>
|
||||
<span style={{ display: 'none' }}>{label}</span>
|
||||
|
||||
<input
|
||||
className='setting-text new-list-form__input'
|
||||
value={value}
|
||||
<Input
|
||||
type='text'
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
placeholder={label}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
placeholder={label}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
// className='new-list-form__btn'
|
||||
disabled={disabled}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{create}
|
||||
</Button>
|
||||
</form>
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{create}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -93,13 +93,13 @@ const Lists: React.FC = () => {
|
|||
itemClassName='py-2'
|
||||
>
|
||||
{lists.map((list: any) => (
|
||||
<Link key={list.get('id')} to={`/list/${list.get('id')}`} className='flex items-center gap-1.5 p-2 text-black dark:text-white hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg'>
|
||||
<Link key={list.id} to={`/list/${list.id}`} className='flex items-center gap-1.5 p-2 text-black dark:text-white hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg'>
|
||||
<Icon src={require('@tabler/icons/icons/list.svg')} fixedWidth />
|
||||
<span className='flex-grow'>
|
||||
{list.get('title')}
|
||||
{list.title}
|
||||
</span>
|
||||
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/icons/pencil.svg')} onClick={handleEditClick(list.get('id'))} title={intl.formatMessage(messages.editList)} />
|
||||
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/icons/trash.svg')} onClick={handleDeleteClick(list.get('id'))} title={intl.formatMessage(messages.deleteList)} />
|
||||
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/icons/pencil.svg')} onClick={handleEditClick(list.id)} title={intl.formatMessage(messages.editList)} />
|
||||
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/icons/trash.svg')} onClick={handleDeleteClick(list.id)} title={intl.formatMessage(messages.deleteList)} />
|
||||
</Link>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
|
|
@ -107,7 +107,7 @@ const Header = () => {
|
|||
)}
|
||||
</HStack>
|
||||
|
||||
<HStack space={2} className='xl:hidden'>
|
||||
<HStack space={2} className='xl:hidden shrink-0'>
|
||||
<Button to='/login' theme='secondary'>
|
||||
{intl.formatMessage(messages.login)}
|
||||
</Button>
|
||||
|
|
|
@ -30,7 +30,7 @@ const ColorPicker: React.FC<IColorPicker> = ({ style, value, onClose, onChange }
|
|||
document.removeEventListener('click', handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', handleDocumentClick);
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pickerStyle: React.CSSProperties = {
|
||||
...style,
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { render, screen, waitFor } from '../../../jest/test-helpers';
|
||||
import { normalizeAccount } from '../../../normalizers';
|
||||
import UI from '../index';
|
||||
|
||||
const TestableComponent = () => (
|
||||
<Switch>
|
||||
<Route path='/@:username/posts/:statusId' exact><UI /></Route>
|
||||
<Route path='/@:username/media' exact><UI /></Route>
|
||||
<Route path='/@:username' exact><UI /></Route>
|
||||
<Route path='/login' exact><span data-testid='sign-in'>Sign in</span></Route>
|
||||
</Switch>
|
||||
);
|
||||
|
||||
describe('<UI />', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
me: false,
|
||||
accounts: ImmutableMap({
|
||||
'1': normalizeAccount({
|
||||
id: '1',
|
||||
acct: 'username',
|
||||
display_name: 'My name',
|
||||
avatar: 'test.jpg',
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('when logged out', () => {
|
||||
describe('with guest experience disabled', () => {
|
||||
beforeEach(() => {
|
||||
store = { ...store, soapbox: ImmutableMap({ guestExperience: false }) };
|
||||
});
|
||||
|
||||
describe('when viewing a Profile Page', () => {
|
||||
it('should render the Profile page', async() => {
|
||||
render(
|
||||
<TestableComponent />,
|
||||
{},
|
||||
store,
|
||||
{ initialEntries: ['/@username'] },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cta-banner')).toHaveTextContent('Sign up now to discuss');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when viewing a Status Page', () => {
|
||||
it('should render the Status page', async() => {
|
||||
render(
|
||||
<TestableComponent />,
|
||||
{},
|
||||
store,
|
||||
{ initialEntries: ['/@username/posts/12'] },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cta-banner')).toHaveTextContent('Sign up now to discuss');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when viewing any other page', () => {
|
||||
it('should redirect to the login page', async() => {
|
||||
render(
|
||||
<TestableComponent />,
|
||||
{},
|
||||
store,
|
||||
{ initialEntries: ['/@username/media'] },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sign-in')).toHaveTextContent('Sign in');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,8 +5,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|||
import { HotKeys } from 'react-hotkeys';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Switch, useHistory } from 'react-router-dom';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { Switch, useHistory, matchPath, Redirect } from 'react-router-dom';
|
||||
|
||||
import { fetchFollowRequests } from 'soapbox/actions/accounts';
|
||||
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
|
||||
|
@ -608,7 +607,14 @@ const UI: React.FC = ({ children }) => {
|
|||
// Wait for login to succeed or fail
|
||||
if (me === null) return null;
|
||||
|
||||
if (!me && !guestExperience) {
|
||||
const isProfileOrStatusPage = !!matchPath(
|
||||
history.location.pathname,
|
||||
['/@:username', '/@:username/posts/:statusId'],
|
||||
);
|
||||
|
||||
// Require login if Guest Experience is disabled and we're not trying
|
||||
// to render a Profile or Status.
|
||||
if (!me && (!guestExperience && !isProfileOrStatusPage)) {
|
||||
cacheCurrentUrl(history.location);
|
||||
return <Redirect to='/login' />;
|
||||
}
|
||||
|
|
|
@ -15,3 +15,17 @@ jest.mock('uuid', () => ({ v4: jest.fn(() => '1') }));
|
|||
|
||||
const intersectionObserverMock = () => ({ observe: () => null, disconnect: () => null });
|
||||
window.IntersectionObserver = jest.fn().mockImplementation(intersectionObserverMock);
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // Deprecated
|
||||
removeListener: jest.fn(), // Deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
EmojiRecord,
|
||||
FieldRecord,
|
||||
InstanceRecord,
|
||||
ListRecord,
|
||||
MentionRecord,
|
||||
NotificationRecord,
|
||||
PollRecord,
|
||||
|
@ -28,6 +29,7 @@ type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
|||
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||
type Field = ReturnType<typeof FieldRecord>;
|
||||
type Instance = ReturnType<typeof InstanceRecord>;
|
||||
type List = ReturnType<typeof ListRecord>;
|
||||
type Mention = ReturnType<typeof MentionRecord>;
|
||||
type Notification = ReturnType<typeof NotificationRecord>;
|
||||
type Poll = ReturnType<typeof PollRecord>;
|
||||
|
@ -61,6 +63,7 @@ export {
|
|||
Emoji,
|
||||
Field,
|
||||
Instance,
|
||||
List,
|
||||
Mention,
|
||||
Notification,
|
||||
Poll,
|
||||
|
|
|
@ -44,7 +44,6 @@
|
|||
@import 'components/status';
|
||||
@import 'components/reply-mentions';
|
||||
@import 'components/detailed-status';
|
||||
@import 'components/list-forms';
|
||||
@import 'components/media-gallery';
|
||||
@import 'components/notification';
|
||||
@import 'components/display-name';
|
||||
|
|
|
@ -1,105 +0,0 @@
|
|||
.list-editor {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
h4 {
|
||||
padding: 15px 0;
|
||||
background: var(--background-color);
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__accounts {
|
||||
background: var(--background-color);
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.account__display-name {
|
||||
&:hover strong {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.account__avatar {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 10px;
|
||||
|
||||
> label {
|
||||
flex: 1 1;
|
||||
}
|
||||
|
||||
> .search__icon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
> .button {
|
||||
width: 80px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-adder {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
&__account {
|
||||
background: var(--background-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__lists {
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 4px;
|
||||
border-bottom: 1px solid var(--brand-color--med);
|
||||
}
|
||||
|
||||
.list__wrapper {
|
||||
display: flex;
|
||||
|
||||
.account__relationship {
|
||||
padding: 8px 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.list__display-name {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.new-list-form,
|
||||
.edit-list-form {
|
||||
&__btn {
|
||||
margin-left: 6px;
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
|
@ -339,33 +339,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.compose-modal__content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
overflow-y: hidden;
|
||||
|
||||
&--scroll {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.timeline-compose-block {
|
||||
background: transparent !important;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
.compose-form {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reply-mentions-modal__accounts {
|
||||
display: block;
|
||||
flex-direction: row;
|
||||
|
|
|
@ -118,13 +118,3 @@ input.search__input {
|
|||
right: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-editor__search {
|
||||
.search__icon {
|
||||
position: relative;
|
||||
|
||||
.svg-icon {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
62
package.json
62
package.json
|
@ -37,19 +37,19 @@
|
|||
"not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.15.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.14.5",
|
||||
"@babel/plugin-proposal-decorators": "^7.15.4",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.15.6",
|
||||
"@babel/core": "^7.18.2",
|
||||
"@babel/plugin-proposal-class-properties": "^7.17.12",
|
||||
"@babel/plugin-proposal-decorators": "^7.18.2",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.18.0",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-react-inline-elements": "^7.14.5",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.14.9",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.14.5",
|
||||
"@babel/plugin-transform-runtime": "^7.15.0",
|
||||
"@babel/preset-env": "^7.15.6",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"@babel/runtime": "^7.15.4",
|
||||
"@babel/plugin-transform-react-inline-elements": "^7.16.7",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.17.12",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.16.7",
|
||||
"@babel/plugin-transform-runtime": "^7.18.2",
|
||||
"@babel/preset-env": "^7.18.2",
|
||||
"@babel/preset-react": "^7.17.12",
|
||||
"@babel/preset-typescript": "^7.17.12",
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@fontsource/inter": "^4.5.1",
|
||||
"@fontsource/roboto": "^4.5.0",
|
||||
"@gamestdio/websocket": "^0.3.2",
|
||||
|
@ -92,9 +92,9 @@
|
|||
"autoprefixer": "^10.4.2",
|
||||
"axios": "^0.27.2",
|
||||
"axios-mock-adapter": "^1.18.1",
|
||||
"babel-loader": "^8.2.2",
|
||||
"babel-loader": "^8.2.5",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-preval": "^5.0.0",
|
||||
"babel-plugin-preval": "^5.1.0",
|
||||
"babel-plugin-react-intl": "^7.5.20",
|
||||
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
|
||||
"babel-plugin-transform-require-context": "^0.1.1",
|
||||
|
@ -107,8 +107,8 @@
|
|||
"copy-webpack-plugin": "^9.0.1",
|
||||
"core-js": "^3.15.2",
|
||||
"cryptocurrency-icons": "^0.17.2",
|
||||
"css-loader": "^5.2.6",
|
||||
"cssnano": "^4.1.10",
|
||||
"css-loader": "^6.7.1",
|
||||
"cssnano": "^5.1.10",
|
||||
"detect-passive-events": "^2.0.0",
|
||||
"dotenv": "^8.0.0",
|
||||
"emoji-datasource": "5.0.0",
|
||||
|
@ -118,13 +118,13 @@
|
|||
"escape-html": "^1.0.3",
|
||||
"exif-js": "^2.3.0",
|
||||
"feather-icons": "^4.28.0",
|
||||
"fork-ts-checker-webpack-plugin": "^6.4.0",
|
||||
"fork-ts-checker-webpack-plugin": "^7.2.11",
|
||||
"history": "^4.10.1",
|
||||
"html-webpack-harddisk-plugin": "^2.0.0",
|
||||
"html-webpack-plugin": "^5.3.2",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"http-link-header": "^1.0.2",
|
||||
"immutable": "^4.0.0",
|
||||
"imports-loader": "^1.0.0",
|
||||
"imports-loader": "^4.0.0",
|
||||
"intersection-observer": "^0.12.0",
|
||||
"intl": "^1.2.5",
|
||||
"intl-messageformat": "^9.0.0",
|
||||
|
@ -136,14 +136,14 @@
|
|||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.7.11",
|
||||
"mark-loader": "^0.1.6",
|
||||
"marky": "^1.2.1",
|
||||
"mini-css-extract-plugin": "^1.6.2",
|
||||
"marky": "^1.2.4",
|
||||
"mini-css-extract-plugin": "^2.6.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"object-fit-images": "^3.2.3",
|
||||
"object.values": "^1.1.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"postcss": "^8.4.5",
|
||||
"postcss-loader": "^4.0.3",
|
||||
"postcss": "^8.4.14",
|
||||
"postcss-loader": "^7.0.0",
|
||||
"postcss-object-fit-images": "^1.1.2",
|
||||
"process": "^0.11.10",
|
||||
"prop-types": "^15.5.10",
|
||||
|
@ -180,22 +180,22 @@
|
|||
"requestidlecallback": "^0.3.0",
|
||||
"reselect": "^4.0.0",
|
||||
"sass": "^1.20.3",
|
||||
"sass-loader": "^10.0.0",
|
||||
"sass-loader": "^13.0.0",
|
||||
"semver": "^7.3.2",
|
||||
"stringz": "^2.0.0",
|
||||
"substring-trie": "^1.0.2",
|
||||
"terser-webpack-plugin": "^5.2.3",
|
||||
"tiny-queue": "^0.2.1",
|
||||
"ts-loader": "^9.2.6",
|
||||
"ts-loader": "^9.3.0",
|
||||
"tslib": "^2.3.1",
|
||||
"twemoji": "https://github.com/twitter/twemoji#v13.0.2",
|
||||
"typescript": "^4.4.4",
|
||||
"util": "^0.12.4",
|
||||
"uuid": "^8.3.2",
|
||||
"webpack": "^5.52.0",
|
||||
"webpack-assets-manifest": "^5.0.6",
|
||||
"webpack-bundle-analyzer": "^4.4.2",
|
||||
"webpack-cli": "^4.8.0",
|
||||
"webpack": "^5.72.1",
|
||||
"webpack-assets-manifest": "^5.1.0",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
"webpack-cli": "^4.9.2",
|
||||
"webpack-deadcode-plugin": "^0.1.16",
|
||||
"webpack-merge": "^5.8.0",
|
||||
"wicg-inert": "^3.1.1"
|
||||
|
@ -206,7 +206,7 @@
|
|||
"@typescript-eslint/eslint-plugin": "^5.15.0",
|
||||
"@typescript-eslint/parser": "^5.15.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^27.5.1",
|
||||
"babel-jest": "^28.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^7.0.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
|
@ -227,7 +227,7 @@
|
|||
"stylelint-scss": "^3.18.0",
|
||||
"tailwindcss": "^3.0.15",
|
||||
"ts-jest": "^27.1.4",
|
||||
"webpack-dev-server": "^4.1.0",
|
||||
"webpack-dev-server": "^4.9.1",
|
||||
"yargs": "^16.0.3"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue