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

View File

@ -12,13 +12,6 @@ import { add, remove, activate, reset, updateToken } from './actions/pure-action
export { updateToken, activate, remove }; export { updateToken, activate, remove };
/**
* @param {Account|object} account
* @param {string} account.token
* @param {string} account.refreshToken
*
* @returns {Function}
*/
export function authenticate( export function authenticate(
account: account:
| Account | Account
@ -59,6 +52,7 @@ export function authenticate(
email: user.email, email: user.email,
token: newToken, token: newToken,
refreshToken: newRefreshToken, refreshToken: newRefreshToken,
isDeleted: user.isDeleted,
}; };
dispatch(add(newAccount)); dispatch(add(newAccount));
dispatch(activate(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 expect from 'app/test/unexpected';
import { updateToken } from './actions'; 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 { AccountsState } from './index';
import accounts, { Account } from './reducer'; import accounts, { Account } from './reducer';
@ -10,7 +10,9 @@ const account: Account = {
username: 'username', username: 'username',
email: 'email@test.com', email: 'email@test.com',
token: 'foo', token: 'foo',
} as Account; refreshToken: '',
isDeleted: false,
};
describe('Accounts reducer', () => { describe('Accounts reducer', () => {
let initial: AccountsState; 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; email: string;
token: string; token: string;
refreshToken: string | null; refreshToken: string | null;
isDeleted: boolean;
}; };
export type State = { export type State = {
@ -23,6 +24,23 @@ export function getAvailableAccounts(state: { accounts: State }): Array<Account>
return state.accounts.available; 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( export default function accounts(
state: State = { state: State = {
active: null, active: null,
@ -90,27 +108,33 @@ export default function accounts(
} }
case 'accounts:updateToken': { case 'accounts:updateToken': {
if (typeof action.payload !== 'string') { return partiallyUpdateActiveAccount(state, {
throw new Error('payload must be a jwt token'); token: action.payload,
} });
}
const { payload } = action; case 'accounts:markAsDeleted': {
return partiallyUpdateActiveAccount(state, {
return { isDeleted: action.payload,
...state, });
available: state.available.map((account) => {
if (account.id === state.active) {
return {
...account,
token: payload,
};
}
return { ...account };
}),
};
} }
} }
return state; 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 <div
className={clsx(styles.item, { className={clsx(styles.item, {
[styles.inactiveItem]: selectedAccount && selectedAccount !== account.id, [styles.inactiveItem]: selectedAccount && selectedAccount !== account.id,
[styles.deletedAccount]: account.isDeleted,
})} })}
key={account.id} key={account.id}
data-e2e-account-id={account.id} data-e2e-account-id={account.id}
onClick={onAccountClickCallback(account)} 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.accountInfo}>
<div className={styles.accountUsername}>{account.username}</div> <div className={styles.accountUsername}>{account.username}</div>
<div className={styles.accountEmail}>{account.email}</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 { connect } from 'app/functions';
import BaseAuthBody from 'app/components/auth/BaseAuthBody'; import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import { getSortedAccounts } from 'app/components/accounts/reducer';
import type { Account } from 'app/components/accounts'; import type { Account } from 'app/components/accounts';
import AccountSwitcher from './AccountSwitcher'; 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 // So to provide accounts list to the component, I'll create connected version of
// the composes with already provided accounts list // the composes with already provided accounts list
const ConnectedAccountSwitcher = connect((state) => ({ const ConnectedAccountSwitcher = connect((state) => ({
accounts: state.accounts.available, accounts: getSortedAccounts(state),
}))(AccountSwitcher); }))(AccountSwitcher);
export default class ChooseAccountBody extends BaseAuthBody { export default class ChooseAccountBody extends BaseAuthBody {

View File

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

View File

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

View File

@ -1,7 +1,13 @@
@import '~app/components/ui/colors.scss'; @import '~app/components/ui/colors.scss';
.pseudoAvatarWrapper {
position: relative;
display: inline-flex; // Needed to get right position of the cross icon
}
.pseudoAvatar { .pseudoAvatar {
composes: minecraft-character from '~app/components/ui/icons.scss'; composes: minecraft-character from '~app/components/ui/icons.scss';
font-size: 1em;
&0 { &0 {
color: $green; color: $green;
@ -30,4 +36,18 @@
&6 { &6 {
color: $red; 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) => ( {available.map((account, index) => (
<div <div
className={clsx(styles.item, styles.accountSwitchItem)} className={clsx(styles.item, styles.accountSwitchItem, {
[styles.deletedAccountItem]: account.isDeleted,
})}
key={account.id} key={account.id}
data-e2e-account-id={account.id} data-e2e-account-id={account.id}
onClick={onAccountClickCallback(account)} onClick={onAccountClickCallback(account)}
> >
<PseudoAvatar index={index + 1} className={styles.accountIcon} /> <PseudoAvatar index={index + 1} deleted={account.isDeleted} className={styles.accountIcon} />
<div <div
className={styles.logoutIcon} className={styles.logoutIcon}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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