mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-11-30 02:32:58 +05:30
Implemented visual indication for deleted accounts [deploy dev]
This commit is contained in:
parent
8075192472
commit
18a8037a0d
@ -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', () => {
|
||||
|
@ -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));
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
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,
|
||||
token: payload,
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...account };
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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)}
|
||||
|
@ -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 {
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
@ -420,6 +420,7 @@ describe('CompleteState', () => {
|
||||
username: 'thatUsername',
|
||||
token: '',
|
||||
refreshToken: '',
|
||||
isDeleted: false,
|
||||
};
|
||||
|
||||
context.getState.returns({
|
||||
|
Loading…
Reference in New Issue
Block a user