Implemented visual indication for deleted accounts [deploy dev]

This commit is contained in:
ErickSkrauch 2020-10-27 01:46:57 +03:00
parent 8075192472
commit 18a8037a0d
17 changed files with 158 additions and 40 deletions

View File

@ -11,6 +11,7 @@ import { setLogin } from 'app/components/auth/actions';
import { Dispatch, State as RootState } from 'app/types';
import { Account } from './reducer';
import { User } from 'app/components/user';
jest.mock('app/i18n', () => ({
en: {
@ -32,19 +33,21 @@ jest.mock('app/i18n', () => ({
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJlbHl8MSJ9.pRJ7vakt2eIscjqwG__KhSxKb3qwGsdBBeDbBffJs_I';
const legacyToken = 'eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOjF9.cRF-sQNrwWQ94xCb3vWioVdjxAZeefEE7GMGwh7708o';
const account = {
const account: Account = {
id: 1,
username: 'username',
email: 'email@test.com',
token,
refreshToken: 'bar',
isDeleted: false,
};
const user = {
const user: Partial<User> = {
id: 1,
username: 'username',
email: 'email@test.com',
lang: 'be',
isDeleted: false,
};
describe('components/accounts/actions', () => {

View File

@ -12,13 +12,6 @@ import { add, remove, activate, reset, updateToken } from './actions/pure-action
export { updateToken, activate, remove };
/**
* @param {Account|object} account
* @param {string} account.token
* @param {string} account.refreshToken
*
* @returns {Function}
*/
export function authenticate(
account:
| Account
@ -59,6 +52,7 @@ export function authenticate(
email: user.email,
token: newToken,
refreshToken: newRefreshToken,
isDeleted: user.isDeleted,
};
dispatch(add(newAccount));
dispatch(activate(newAccount));

View File

@ -59,4 +59,16 @@ export function updateToken(token: string): UpdateTokenAction {
};
}
export type Action = AddAction | RemoveAction | ActivateAction | ResetAction | UpdateTokenAction;
interface MarkAsDeletedAction extends ReduxAction {
type: 'accounts:markAsDeleted';
payload: boolean;
}
export function markAsDeleted(isDeleted: boolean): MarkAsDeletedAction {
return {
type: 'accounts:markAsDeleted',
payload: isDeleted,
};
}
export type Action = AddAction | RemoveAction | ActivateAction | ResetAction | UpdateTokenAction | MarkAsDeletedAction;

View File

@ -1,7 +1,7 @@
import expect from 'app/test/unexpected';
import { updateToken } from './actions';
import { add, remove, activate, reset } from './actions/pure-actions';
import { add, remove, activate, reset, markAsDeleted } from './actions/pure-actions';
import { AccountsState } from './index';
import accounts, { Account } from './reducer';
@ -10,7 +10,9 @@ const account: Account = {
username: 'username',
email: 'email@test.com',
token: 'foo',
} as Account;
refreshToken: '',
isDeleted: false,
};
describe('Accounts reducer', () => {
let initial: AccountsState;
@ -124,4 +126,20 @@ describe('Accounts reducer', () => {
});
});
});
describe('accounts:markAsDeleted', () => {
it('should mark account as deleted', () => {
const isDeleted = true;
expect(accounts({ active: account.id, available: [account] }, markAsDeleted(isDeleted)), 'to satisfy', {
active: account.id,
available: [
{
...account,
isDeleted,
},
],
});
});
});
});

View File

@ -6,6 +6,7 @@ export type Account = {
email: string;
token: string;
refreshToken: string | null;
isDeleted: boolean;
};
export type State = {
@ -23,6 +24,23 @@ export function getAvailableAccounts(state: { accounts: State }): Array<Account>
return state.accounts.available;
}
/**
* Move deleted accounts to the end of the accounts list.
*/
export function getSortedAccounts(state: { accounts: State }): ReadonlyArray<Account> {
return state.accounts.available.sort((acc1, acc2) => {
if (acc1.isDeleted && !acc2.isDeleted) {
return 1;
}
if (!acc1.isDeleted && acc2.isDeleted) {
return -1;
}
return 0;
});
}
export default function accounts(
state: State = {
active: null,
@ -90,27 +108,33 @@ export default function accounts(
}
case 'accounts:updateToken': {
if (typeof action.payload !== 'string') {
throw new Error('payload must be a jwt token');
}
return partiallyUpdateActiveAccount(state, {
token: action.payload,
});
}
const { payload } = action;
return {
...state,
available: state.available.map((account) => {
if (account.id === state.active) {
return {
...account,
token: payload,
};
}
return { ...account };
}),
};
case 'accounts:markAsDeleted': {
return partiallyUpdateActiveAccount(state, {
isDeleted: action.payload,
});
}
}
return state;
}
function partiallyUpdateActiveAccount(state: State, payload: Partial<Account>): State {
return {
...state,
available: state.available.map((account) => {
if (account.id === state.active) {
return {
...account,
...payload,
};
}
return { ...account };
}),
};
}

View File

@ -36,12 +36,13 @@ const AccountSwitcher: ComponentType<Props> = ({ accounts, onAccountClick }) =>
<div
className={clsx(styles.item, {
[styles.inactiveItem]: selectedAccount && selectedAccount !== account.id,
[styles.deletedAccount]: account.isDeleted,
})}
key={account.id}
data-e2e-account-id={account.id}
onClick={onAccountClickCallback(account)}
>
<PseudoAvatar index={index} className={styles.accountIcon} />
<PseudoAvatar index={index} deleted={account.isDeleted} className={styles.accountAvatar} />
<div className={styles.accountInfo}>
<div className={styles.accountUsername}>{account.username}</div>
<div className={styles.accountEmail}>{account.email}</div>

View File

@ -4,6 +4,7 @@ import { FormattedMessage as Message } from 'react-intl';
import { connect } from 'app/functions';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import { getSortedAccounts } from 'app/components/accounts/reducer';
import type { Account } from 'app/components/accounts';
import AccountSwitcher from './AccountSwitcher';
@ -15,7 +16,7 @@ import styles from './chooseAccount.scss';
// So to provide accounts list to the component, I'll create connected version of
// the composes with already provided accounts list
const ConnectedAccountSwitcher = connect((state) => ({
accounts: state.accounts.available,
accounts: getSortedAccounts(state),
}))(AccountSwitcher);
export default class ChooseAccountBody extends BaseAuthBody {

View File

@ -35,7 +35,7 @@ $border: 1px solid lighter($black);
pointer-events: none;
}
.accountIcon {
.accountAvatar {
font-size: 35px;
margin-right: 15px;
}
@ -56,12 +56,20 @@ $border: 1px solid lighter($black);
@extend %overflowText;
font-family: $font-family-title;
color: #fff;
.deletedAccount & {
color: #aaa;
}
}
.accountEmail {
@extend %overflowText;
color: #666;
color: #999;
font-size: 12px;
.deletedAccount & {
color: #666;
}
}
.nextIcon {

View File

@ -5,11 +5,23 @@ import styles from './pseudoAvatar.scss';
interface Props {
index?: number;
deleted?: boolean;
className?: string;
}
const PseudoAvatar: ComponentType<Props> = ({ index = 0, className }) => (
<div className={clsx(styles.pseudoAvatar, styles[`pseudoAvatar${index % 7}`], className)} />
const PseudoAvatar: ComponentType<Props> = ({ index = 0, deleted, className }) => (
<div
className={clsx(
styles.pseudoAvatarWrapper,
{
[styles.deletedPseudoAvatar]: deleted,
},
className,
)}
>
<div className={clsx(styles.pseudoAvatar, styles[`pseudoAvatar${index % 7}`])} />
{deleted ? <div className={styles.deletedIcon} /> : ''}
</div>
);
export default PseudoAvatar;

View File

@ -1,7 +1,13 @@
@import '~app/components/ui/colors.scss';
.pseudoAvatarWrapper {
position: relative;
display: inline-flex; // Needed to get right position of the cross icon
}
.pseudoAvatar {
composes: minecraft-character from '~app/components/ui/icons.scss';
font-size: 1em;
&0 {
color: $green;
@ -30,4 +36,18 @@
&6 {
color: $red;
}
.deletedPseudoAvatar & {
color: #ccc;
}
}
.deletedIcon {
composes: close from '~app/components/ui/icons.scss';
position: absolute;
top: 0.16em;
left: -0.145em;
font-size: 0.7em;
color: rgba($red, 0.75);
}

View File

@ -72,12 +72,14 @@ const AccountSwitcher: ComponentType<Props> = ({
{available.map((account, index) => (
<div
className={clsx(styles.item, styles.accountSwitchItem)}
className={clsx(styles.item, styles.accountSwitchItem, {
[styles.deletedAccountItem]: account.isDeleted,
})}
key={account.id}
data-e2e-account-id={account.id}
onClick={onAccountClickCallback(account)}
>
<PseudoAvatar index={index + 1} className={styles.accountIcon} />
<PseudoAvatar index={index + 1} deleted={account.isDeleted} className={styles.accountIcon} />
<div
className={styles.logoutIcon}

View File

@ -10,6 +10,7 @@ const activeAccount = {
email: 'mock@ely.by',
refreshToken: '',
token: '',
isDeleted: false,
};
storiesOf('Components/Userbar', module)
@ -27,6 +28,15 @@ storiesOf('Components/Userbar', module)
email: 'mock-user2@ely.by',
token: '',
refreshToken: '',
isDeleted: false,
},
{
id: 3,
username: 'DeletedUser',
email: 'i-am-deleted@ely.by',
token: '',
refreshToken: '',
isDeleted: true,
},
]}
onSwitchAccount={async (account) => action('onSwitchAccount')(account)}

View File

@ -96,11 +96,19 @@ $lightBorderColor: #eee;
font-family: $font-family-title;
font-size: 14px;
color: #666;
.deletedAccountItem & {
color: #999;
}
}
.accountEmail {
font-size: 10px;
color: #999;
.deletedAccountItem & {
color: #a9a9a9;
}
}
.addIcon {

View File

@ -3,6 +3,7 @@ import React, { ComponentType, useCallback, useContext } from 'react';
import { useReduxDispatch } from 'app/functions';
import { restoreAccount } from 'app/services/api/accounts';
import { updateUser } from 'app/components/user/actions';
import { markAsDeleted } from 'app/components/accounts/actions/pure-actions';
import ProfileContext from 'app/components/profile/Context';
import AccountDeleted from 'app/components/profile/AccountDeleted';
@ -17,6 +18,7 @@ const AccountDeletedPage: ComponentType = () => {
isDeleted: false,
}),
);
dispatch(markAsDeleted(false));
context.goToProfile();
}, [dispatch, context]);

View File

@ -5,6 +5,7 @@ import { deleteAccount } from 'app/services/api/accounts';
import { FormModel } from 'app/components/ui/form';
import DeleteAccount from 'app/components/profile/deleteAccount';
import { updateUser } from 'app/components/user/actions';
import { markAsDeleted } from 'app/components/accounts/actions/pure-actions';
import ProfileContext from 'app/components/profile/Context';
const DeleteAccountPage: ComponentType = () => {
@ -21,6 +22,7 @@ const DeleteAccountPage: ComponentType = () => {
isDeleted: true,
}),
);
dispatch(markAsDeleted(true));
context.goToProfile();
}, [context]);

View File

@ -4,7 +4,7 @@ import { Link, useLocation } from 'react-router-dom';
import { useReduxDispatch, useReduxSelector } from 'app/functions';
import { authenticate, revoke } from 'app/components/accounts/actions';
import { Account } from 'app/components/accounts/reducer';
import { Account, getSortedAccounts } from 'app/components/accounts/reducer';
import buttons from 'app/components/ui/buttons.scss';
import LoggedInPanel from 'app/components/userbar/LoggedInPanel';
import * as loader from 'app/services/loader';
@ -20,7 +20,7 @@ interface Props {
const Toolbar: ComponentType<Props> = ({ onLogoClick, account }) => {
const dispatch = useReduxDispatch();
const location = useLocation();
const availableAccounts = useReduxSelector((state) => state.accounts.available);
const availableAccounts = useReduxSelector(getSortedAccounts);
const switchAccount = useCallback((account: Account) => {
loader.show();

View File

@ -420,6 +420,7 @@ describe('CompleteState', () => {
username: 'thatUsername',
token: '',
refreshToken: '',
isDeleted: false,
};
context.getState.returns({