Skip to content

Commit cbf2ea9

Browse files
16025 frontend [ users ] Update team users in realtime (#320)
1 parent de41812 commit cbf2ea9

11 files changed

Lines changed: 293 additions & 23 deletions

File tree

frontend/src/public/api/getActiveUsersCount.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { commonRequest } from './commonRequest';
33

44
export interface IGetActiveUsersCountResponse {
55
activeUsers: number;
6+
tenantsActiveUsers: number;
67
}
78

89
export function getActiveUsersCount() {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { call, put, select } from 'redux-saga/effects';
2+
3+
import { getActiveUsersCount } from '../../../api/getActiveUsersCount';
4+
import { EUserStatus, TUserListItem } from '../../../types/user';
5+
import { activeUsersCountFetchFinished } from '../slice';
6+
import { fetchActiveUsersCount } from '../saga';
7+
import { getAccountsStore } from '../../selectors/user';
8+
9+
const makeUser = (id: number, status = EUserStatus.Active): TUserListItem => ({
10+
id,
11+
firstName: `User ${id}`,
12+
lastName: '',
13+
email: `user-${id}@test.com`,
14+
phone: '',
15+
photo: '',
16+
status,
17+
type: 'user',
18+
isAdmin: false,
19+
isAccountOwner: false,
20+
});
21+
22+
describe('accounts saga', () => {
23+
it('refreshes tenants count without overwriting local active users count', () => {
24+
const gen = fetchActiveUsersCount();
25+
26+
expect(gen.next().value).toEqual(call(getActiveUsersCount));
27+
expect(gen.next({ activeUsers: 1, tenantsActiveUsers: 4 } as never).value).toEqual(select(getAccountsStore));
28+
expect(gen.next({
29+
users: [],
30+
team: { list: [makeUser(1), makeUser(2), makeUser(3, EUserStatus.Invited)] },
31+
} as never).value).toEqual(put(activeUsersCountFetchFinished({ activeUsers: 2, tenantsActiveUsers: 4 })));
32+
expect(gen.next().done).toBe(true);
33+
});
34+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import accountsReducer, {
2+
activeUsersCountFetchFinished,
3+
removeUserFromWs,
4+
teamFetchFinished,
5+
upsertUserFromWs,
6+
usersFetchFinished,
7+
} from '../slice';
8+
import { EUserStatus, TUserListItem } from '../../../types/user';
9+
10+
const makeUser = (id: number, firstName: string, status = EUserStatus.Active): TUserListItem => ({
11+
id,
12+
firstName,
13+
lastName: '',
14+
email: `${firstName.toLowerCase()}@test.com`,
15+
phone: '',
16+
photo: '',
17+
status,
18+
type: 'user',
19+
isAdmin: false,
20+
isAccountOwner: false,
21+
});
22+
23+
describe('accounts reducer realtime users', () => {
24+
it('keeps websocket users sorted and derives active users count', () => {
25+
let state = accountsReducer(undefined, activeUsersCountFetchFinished({ activeUsers: 1, tenantsActiveUsers: 0 }));
26+
state = accountsReducer(state, usersFetchFinished([
27+
makeUser(1, 'Artyom'),
28+
makeUser(3, 'very-long-invited-user-email-address@example.com', EUserStatus.Invited),
29+
]));
30+
state = accountsReducer(state, teamFetchFinished(state.users));
31+
32+
state = accountsReducer(state, upsertUserFromWs(makeUser(2, 'Artyom')));
33+
34+
expect(state.planInfo.activeUsers).toBe(2);
35+
expect(state.team.list.map((user) => user.id)).toEqual([1, 2, 3]);
36+
});
37+
38+
it('removes websocket deleted user', () => {
39+
let state = accountsReducer(undefined, activeUsersCountFetchFinished({ activeUsers: 2, tenantsActiveUsers: 0 }));
40+
state = accountsReducer(state, usersFetchFinished([
41+
makeUser(1, 'Artyom'),
42+
makeUser(2, 'Artyom'),
43+
]));
44+
state = accountsReducer(state, teamFetchFinished(state.users));
45+
46+
state = accountsReducer(state, removeUserFromWs(2));
47+
48+
expect(state.planInfo.activeUsers).toBe(1);
49+
expect(state.team.list.map((user) => user.id)).toEqual([1]);
50+
});
51+
52+
it('derives active users count from fetched users', () => {
53+
const state = accountsReducer(undefined, usersFetchFinished([
54+
makeUser(1, 'Artyom'),
55+
makeUser(2, 'Invited', EUserStatus.Invited),
56+
]));
57+
58+
expect(state.planInfo.activeUsers).toBe(1);
59+
});
60+
61+
it('derives active users count from fetched team users', () => {
62+
const state = accountsReducer(undefined, teamFetchFinished([
63+
makeUser(1, 'Artyom'),
64+
makeUser(2, 'Test'),
65+
makeUser(3, 'Invited', EUserStatus.Invited),
66+
]));
67+
68+
expect(state.planInfo.activeUsers).toBe(2);
69+
});
70+
71+
it('does not overwrite active users count when websocket arrives before users fetch', () => {
72+
let state = accountsReducer(undefined, activeUsersCountFetchFinished({ activeUsers: 5, tenantsActiveUsers: 0 }));
73+
74+
state = accountsReducer(state, upsertUserFromWs(makeUser(1, 'Artyom')));
75+
76+
expect(state.planInfo.activeUsers).toBe(5);
77+
});
78+
79+
it('keeps active users count when websocket deletion arrives before users fetch', () => {
80+
let state = accountsReducer(undefined, activeUsersCountFetchFinished({ activeUsers: 5, tenantsActiveUsers: 0 }));
81+
82+
state = accountsReducer(state, removeUserFromWs(1));
83+
84+
expect(state.planInfo.activeUsers).toBe(5);
85+
});
86+
});

frontend/src/public/redux/accounts/saga.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,9 @@ import { setGeneralLoaderVisibility } from '../general/actions';
6161
import { auth } from '../../api/auth';
6262
import { startFreeSubscription } from '../../api/startFreeSubscription';
6363
import { getActiveUsersCount, IGetActiveUsersCountResponse } from '../../api/getActiveUsersCount';
64-
import { sortUsersByStatus, sortUsersByNameAsc, sortUsersByNameDesc } from '../../utils/users';
64+
import { getActiveUsers, sortUsersByStatus, sortUsersByNameAsc, sortUsersByNameDesc } from '../../utils/users';
6565
import { getAccountPlan } from '../selectors/accounts';
6666
import { getAbsolutePath } from '../../utils/getAbsolutePath';
67-
import { getTenantsCountStore } from '../selectors/tenants';
6867
import { createUser as createUserApi } from '../../api/createUser';
6968
import { editTeamUser } from '../../api/editTeamUser';
7069

@@ -120,10 +119,14 @@ export function* fetchUsers(
120119

121120
export function* fetchActiveUsersCount() {
122121
try {
123-
const { activeUsers }: IGetActiveUsersCountResponse = yield call(getActiveUsersCount);
124-
const tenantCount: number = yield select(getTenantsCountStore);
125-
126-
yield put(activeUsersCountFetchFinished({ activeUsers, tenantsActiveUsers: tenantCount || 333 }));
122+
const { activeUsers, tenantsActiveUsers }: IGetActiveUsersCountResponse = yield call(getActiveUsersCount);
123+
const accounts: ReturnType<typeof getAccountsStore> = yield select(getAccountsStore);
124+
const localUsers = accounts.team.list.length ? accounts.team.list : accounts.users;
125+
126+
yield put(activeUsersCountFetchFinished({
127+
activeUsers: localUsers.length ? getActiveUsers(localUsers).length : activeUsers,
128+
tenantsActiveUsers,
129+
}));
127130
} catch (error) {
128131
console.info('fetch active users count error : ', error);
129132
}

frontend/src/public/redux/accounts/slice.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createSlice, PayloadAction, createAction } from '@reduxjs/toolkit';
22

33
import { EDeleteUserModalState, IAccounts, IAccountPlan } from '../../types/redux';
4-
import { EUserListSorting, TUserListItem, ICreateUserRequest } from '../../types/user';
4+
import { EUserListSorting, EUserStatus, TUserListItem, ICreateUserRequest } from '../../types/user';
55
import { ESubscriptionPlan } from '../../types/account';
66

77
import {
@@ -59,6 +59,35 @@ function setUserProperties(users: TUserListItem[], userId: number, changedProps:
5959
});
6060
}
6161

62+
const getUserNameForSorting = (user: TUserListItem) => (user.firstName || user.email).toLowerCase();
63+
64+
function sortUsers(users: TUserListItem[], sorting: EUserListSorting) {
65+
const sorted = users.slice().sort((user1, user2) => {
66+
if (getUserNameForSorting(user1) === getUserNameForSorting(user2)) return 0;
67+
68+
return getUserNameForSorting(user1) > getUserNameForSorting(user2) ? 1 : -1;
69+
});
70+
71+
if (sorting === EUserListSorting.NameDesc) {
72+
return sorted.reverse();
73+
}
74+
75+
if (sorting === EUserListSorting.Status) {
76+
return sorted.sort((user1, user2) => {
77+
const user1Status = user1.status === EUserStatus.Invited ? 1 : 0;
78+
const user2Status = user2.status === EUserStatus.Invited ? 1 : 0;
79+
80+
return user1Status - user2Status;
81+
});
82+
}
83+
84+
return sorted;
85+
}
86+
87+
const getActiveUsersCount = (users: TUserListItem[]) => users.filter(
88+
(user) => user.status === EUserStatus.Active && user.type === 'user',
89+
).length;
90+
6291
const accountsSlice = createSlice({
6392
name: 'accounts',
6493
initialState,
@@ -79,6 +108,7 @@ const accountsSlice = createSlice({
79108
teamFetchFinished: (state, action: PayloadAction<TUserListItem[]>) => {
80109
state.team.isLoading = false;
81110
state.team.list = action.payload;
111+
state.planInfo.activeUsers = getActiveUsersCount(action.payload);
82112
},
83113

84114
usersFetchFailed: (state) => {
@@ -89,10 +119,12 @@ const accountsSlice = createSlice({
89119
usersFetchFinished: (state, action: PayloadAction<TUserListItem[]>) => {
90120
state.isLoading = false;
91121
state.users = action.payload;
122+
state.planInfo.activeUsers = getActiveUsersCount(action.payload);
92123
},
93124

94125
activeUsersCountFetchFinished: (state, action: PayloadAction<TActiveUsersCountFetchFinishedPayload>) => {
95126
state.planInfo.activeUsers = action.payload.activeUsers;
127+
state.planInfo.tenantsActiveUsers = action.payload.tenantsActiveUsers;
96128
},
97129

98130
setCurrentPlan: (state, action: PayloadAction<IAccountPlan>) => {
@@ -195,22 +227,31 @@ const accountsSlice = createSlice({
195227

196228
upsertUserFromWs: (state, action: PayloadAction<TUserListItem>) => {
197229
const user = action.payload;
230+
const hasLocalUsers = Boolean(state.users.length || state.team.list.length);
198231
const upsertList = (list: TUserListItem[]) => {
199232
const hasUser = list.some((item) => item.id === user.id);
200-
if (!hasUser) {
201-
return [...list, user];
202-
}
203-
return list.map((item) => (item.id === user.id ? { ...item, ...user } : item));
233+
const nextList = hasUser
234+
? list.map((item) => (item.id === user.id ? { ...item, ...user } : item))
235+
: [...list, user];
236+
237+
return sortUsers(nextList, state.userListSorting);
204238
};
205239

206240
state.users = upsertList(state.users);
207241
state.team.list = upsertList(state.team.list);
242+
if (hasLocalUsers) {
243+
state.planInfo.activeUsers = getActiveUsersCount(state.team.list.length ? state.team.list : state.users);
244+
}
208245
},
209246

210247
removeUserFromWs: (state, action: PayloadAction<number>) => {
248+
const hasLocalUsers = Boolean(state.users.length || state.team.list.length);
211249
const removeFromList = (list: TUserListItem[]) => list.filter((item) => item.id !== action.payload);
212250
state.users = removeFromList(state.users);
213251
state.team.list = removeFromList(state.team.list);
252+
if (hasLocalUsers) {
253+
state.planInfo.activeUsers = getActiveUsersCount(state.team.list.length ? state.team.list : state.users);
254+
}
214255
},
215256
},
216257
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { put, select } from 'redux-saga/effects';
2+
3+
import { setMenuItemCounter } from '../actions';
4+
import { updateCounterSaga } from '../saga';
5+
import { teamFetchFinished, usersFetchFinished } from '../../accounts/slice';
6+
import { getTotalTasksCount } from '../../selectors/tasks';
7+
import { getAccountPlan } from '../../selectors/accounts';
8+
import { getTenantsCountStore } from '../../selectors/tenants';
9+
import { IAccountPlan } from '../../../types/redux';
10+
11+
const plan = { activeUsers: 3 } as IAccountPlan;
12+
13+
describe('menu counter saga', () => {
14+
it('updates team counter after users list changes', () => {
15+
const gen = updateCounterSaga(usersFetchFinished([]));
16+
17+
expect(gen.next().value).toEqual(select(getTotalTasksCount));
18+
expect(gen.next(0 as never).value).toEqual(select(getAccountPlan));
19+
expect(gen.next(plan as never).value).toEqual(select(getTenantsCountStore));
20+
expect(gen.next(0 as never).value).toEqual(put(setMenuItemCounter({ id: 'team', value: 3, type: 'info' })));
21+
expect(gen.next().value).toEqual(put(setMenuItemCounter({ id: 'tenants', value: 0, type: 'info' })));
22+
expect(gen.next().done).toBe(true);
23+
});
24+
25+
it('updates team counter after team list changes', () => {
26+
const gen = updateCounterSaga(teamFetchFinished([]));
27+
28+
expect(gen.next().value).toEqual(select(getTotalTasksCount));
29+
expect(gen.next(0 as never).value).toEqual(select(getAccountPlan));
30+
expect(gen.next(plan as never).value).toEqual(select(getTenantsCountStore));
31+
expect(gen.next(0 as never).value).toEqual(put(setMenuItemCounter({ id: 'team', value: 3, type: 'info' })));
32+
});
33+
});

frontend/src/public/redux/menu/saga.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@
22
import { all, fork, put, select, takeEvery } from 'redux-saga/effects';
33
import { PayloadAction } from '@reduxjs/toolkit';
44
import { EMenuActions, mergeMenuItems, setMenuItemCounter } from './actions';
5-
import { activeUsersCountFetchFinished, setCurrentPlan } from '../accounts/slice';
6-
import { TActiveUsersCountFetchFinishedPayload } from '../accounts/types';
7-
import { IAccountPlan } from '../../types/redux';
5+
import {
6+
activeUsersCountFetchFinished,
7+
removeUserFromWs,
8+
setCurrentPlan,
9+
teamFetchFinished,
10+
upsertUserFromWs,
11+
usersFetchFinished,
12+
} from '../accounts/slice';
813
import { getAuthUser } from '../selectors/user';
914
import { generateMenuItems, createMenuCounter } from '../../utils/menu';
1015
import { IMenuItem } from '../../types/menu';
@@ -31,7 +36,7 @@ export function* generateMenuSaga() {
3136
}
3237
}
3338

34-
type TUpdateCounterAction = PayloadAction<number> | PayloadAction<TActiveUsersCountFetchFinishedPayload> | PayloadAction<IAccountPlan>;
39+
type TUpdateCounterAction = PayloadAction<unknown>;
3540

3641
export function* updateCounterSaga(action: TUpdateCounterAction) {
3742
const tasksCount: ReturnType<typeof getTotalTasksCount> = yield select(getTotalTasksCount);
@@ -45,9 +50,14 @@ export function* updateCounterSaga(action: TUpdateCounterAction) {
4550
},
4651
{
4752
check: () =>
48-
[activeUsersCountFetchFinished.type, setCurrentPlan.type].some(
49-
(t) => t === action.type,
50-
),
53+
[
54+
activeUsersCountFetchFinished.type,
55+
removeUserFromWs.type,
56+
setCurrentPlan.type,
57+
teamFetchFinished.type,
58+
upsertUserFromWs.type,
59+
usersFetchFinished.type,
60+
].some((t) => t === action.type),
5161
getCounters: () =>
5262
[createMenuCounter('team', teamCount), createMenuCounter('tenants', tenantCount)].filter(
5363
Boolean,
@@ -71,7 +81,11 @@ export function* watchUpdateCounter() {
7181
[
7282
changeTasksCount.type,
7383
activeUsersCountFetchFinished.type,
84+
removeUserFromWs.type,
7485
setCurrentPlan.type,
86+
teamFetchFinished.type,
87+
upsertUserFromWs.type,
88+
usersFetchFinished.type,
7589
],
7690
updateCounterSaga,
7791
);

0 commit comments

Comments
 (0)