Skip to content

Commit

Permalink
feat: added user administration (#28)
Browse files Browse the repository at this point in the history
Closes: #3
  • Loading branch information
MaSch0212 authored Jun 6, 2024
1 parent a5b88f5 commit 041aec8
Show file tree
Hide file tree
Showing 26 changed files with 1,160 additions and 242 deletions.
3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@
"@ngrx/router-store": "^17.1.0",
"@ngrx/store": "^17.1.0",
"@ngrx/store-devtools": "^17.1.0",
"copy-to-clipboard": "^3.3.3",
"immer": "^10.0.3",
"primeng": "17.6.0",
"primeng": "17.18.0",
"rxjs": "~7.8.1",
"tslib": "^2.6.2",
"zone.js": "~0.14.3"
Expand Down
419 changes: 238 additions & 181 deletions client/pnpm-lock.yaml

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions client/src/app/+state/users/actions/add-user.action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { inject } from '@angular/core';
import { on } from '@ngrx/store';
import { switchMap } from 'rxjs';

import { ApiCreateUserResponse } from '../../../api/models';
import { UserAdministrationService } from '../../../api/services';
import { User } from '../../../models/parsed-models';
import { assertBody } from '../../../utils/http.utils';
import { createHttpAction, handleHttpAction, onHttpAction, toHttpAction } from '../../action-state';
import { createFunctionalEffect } from '../../functional-effect';
import { Effects, Reducers } from '../../utils';
import { USERS_ACTION_SCOPE } from '../consts';
import { selectUsersActionState } from '../users.selectors';
import { userEntityAdapter, UsersFeatureState } from '../users.state';

export const addUserAction = createHttpAction<Omit<User, 'id' | 'loginToken'>, User>()(
USERS_ACTION_SCOPE,
'Add User'
);

export const addUserReducers: Reducers<UsersFeatureState> = [
on(addUserAction.success, (state, { response }) => userEntityAdapter.upsertOne(response, state)),
handleHttpAction('add', addUserAction),
];

export const addUserEffects: Effects = {
addUser$: createFunctionalEffect.dispatching((api = inject(UserAdministrationService)) =>
onHttpAction(addUserAction, selectUsersActionState('add')).pipe(
switchMap(({ props }) => toHttpAction(createUser(api, props), addUserAction, props))
)
),
};

async function createUser(
api: UserAdministrationService,
props: ReturnType<typeof addUserAction>['props']
) {
const response = await api.createUser({
body: {
alias: props.alias,
roles: props.roles,
playerPreferences: props.playerPreferences,
},
});
return response.ok
? addUserAction.success(props, toUser(assertBody(response)))
: addUserAction.error(props, response);
}

function toUser(body: ApiCreateUserResponse): User {
return {
...body.user,
loginToken: body.loginToken,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { inject } from '@angular/core';
import { on } from '@ngrx/store';
import { produce } from 'immer';
import { switchMap } from 'rxjs';

import { UserAdministrationService } from '../../../api/services';
import { assertBody } from '../../../utils/http.utils';
import { createHttpAction, handleHttpAction, onHttpAction, toHttpAction } from '../../action-state';
import { createFunctionalEffect } from '../../functional-effect';
import { Effects, Reducers } from '../../utils';
import { USERS_ACTION_SCOPE } from '../consts';
import { selectUsersActionState } from '../users.selectors';
import { userEntityAdapter, UsersFeatureState } from '../users.state';

export const loadUserLoginTokenAction = createHttpAction<
{ userId: string },
{ loginToken: string }
>()(USERS_ACTION_SCOPE, 'Load User Login Token');

export const loadUserLoginTokenReducers: Reducers<UsersFeatureState> = [
on(loadUserLoginTokenAction.success, (state, { props, response }) =>
userEntityAdapter.mapOne(
{
id: props.userId,
map: produce(draft => {
draft.loginToken = response.loginToken;
}),
},
state
)
),
handleHttpAction('loadLoginToken', loadUserLoginTokenAction),
];

export const loadUserLoginTokenEffects: Effects = {
loadUserLoginToken$: createFunctionalEffect.dispatching(
(api = inject(UserAdministrationService)) =>
onHttpAction(loadUserLoginTokenAction, selectUsersActionState('loadLoginToken')).pipe(
switchMap(({ props }) =>
toHttpAction(getUserLoginToken(api, props), loadUserLoginTokenAction, props)
)
)
),
};

async function getUserLoginToken(
api: UserAdministrationService,
props: ReturnType<typeof loadUserLoginTokenAction>['props']
) {
const response = await api.getUserLoginToken({ userId: props.userId });
return response.ok
? loadUserLoginTokenAction.success(props, assertBody(response))
: loadUserLoginTokenAction.error(props, response);
}
41 changes: 41 additions & 0 deletions client/src/app/+state/users/actions/remove-user.action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { inject } from '@angular/core';
import { on } from '@ngrx/store';
import { switchMap } from 'rxjs';

import { UserAdministrationService } from '../../../api/services';
import { createHttpAction, handleHttpAction, onHttpAction, toHttpAction } from '../../action-state';
import { createFunctionalEffect } from '../../functional-effect';
import { Effects, Reducers } from '../../utils';
import { USERS_ACTION_SCOPE } from '../consts';
import { selectUsersActionState } from '../users.selectors';
import { userEntityAdapter, UsersFeatureState } from '../users.state';

export const removeUserAction = createHttpAction<{ userId: string }>()(
USERS_ACTION_SCOPE,
'Remove User'
);

export const removeUserReducers: Reducers<UsersFeatureState> = [
on(removeUserAction.success, (state, { props }) =>
userEntityAdapter.removeOne(props.userId, state)
),
handleHttpAction('remove', removeUserAction),
];

export const removeUserEffects: Effects = {
removeUser$: createFunctionalEffect.dispatching((api = inject(UserAdministrationService)) =>
onHttpAction(removeUserAction, selectUsersActionState('remove')).pipe(
switchMap(({ props }) => toHttpAction(deleteUser(api, props), removeUserAction, props))
)
),
};

async function deleteUser(
api: UserAdministrationService,
props: ReturnType<typeof removeUserAction>['props']
) {
const response = await api.deleteUser({ userId: props.userId });
return response.ok
? removeUserAction.success(props, undefined)
: removeUserAction.error(props, response);
}
68 changes: 68 additions & 0 deletions client/src/app/+state/users/actions/update-user.action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { inject } from '@angular/core';
import { concatLatestFrom } from '@ngrx/effects';
import { on, Store } from '@ngrx/store';
import { filter, switchMap } from 'rxjs';

import { ApiUpdateUserRequest } from '../../../api/models';
import { UserAdministrationService } from '../../../api/services';
import { User } from '../../../models/parsed-models';
import { createHttpAction, handleHttpAction, onHttpAction, toHttpAction } from '../../action-state';
import { createFunctionalEffect } from '../../functional-effect';
import { Reducers } from '../../utils';
import { USERS_ACTION_SCOPE } from '../consts';
import { selectUser, selectUsersActionState } from '../users.selectors';
import { userEntityAdapter, UsersFeatureState } from '../users.state';

export const updateUserAction = createHttpAction<User>()(USERS_ACTION_SCOPE, 'Update User');

export const updateUserReducers: Reducers<UsersFeatureState> = [
on(updateUserAction.success, (state, { props }) => userEntityAdapter.upsertOne(props, state)),
handleHttpAction('update', updateUserAction),
];

export const updateUserEffects = {
updateUser$: createFunctionalEffect.dispatching(
(store = inject(Store), api = inject(UserAdministrationService)) =>
onHttpAction(updateUserAction, selectUsersActionState('update')).pipe(
concatLatestFrom(({ props }) => store.select(selectUser(props.id))),
filter(([, oldUser]) => !!oldUser),
switchMap(([{ props }, oldUser]) =>
toHttpAction(updateUser(api, props, oldUser!), updateUserAction, props)

Check warning on line 30 in client/src/app/+state/users/actions/update-user.action.ts

View workflow job for this annotation

GitHub Actions / Build

Forbidden non-null assertion
)
)
),
};

async function updateUser(
api: UserAdministrationService,
props: ReturnType<typeof updateUserAction>['props'],
oldUser: User
) {
const request: ApiUpdateUserRequest = {
alias: props.alias !== oldUser.alias ? props.alias : undefined,
addRoles: props.roles.filter(role => !oldUser.roles.includes(role)),
removeRoles: oldUser.roles.filter(role => !props.roles.includes(role)),
playerPreferences: {
addAvoid: props.playerPreferences.avoid.filter(
avoid => !oldUser.playerPreferences.avoid.includes(avoid)
),
removeAvoid: oldUser.playerPreferences.avoid.filter(
avoid => !props.playerPreferences.avoid.includes(avoid)
),
addPrefer: props.playerPreferences.prefer.filter(
prefer => !oldUser.playerPreferences.prefer.includes(prefer)
),
removePrefer: oldUser.playerPreferences.prefer.filter(
prefer => !props.playerPreferences.prefer.includes(prefer)
),
},
};

const response = await api.updateUser({
userId: oldUser.id,
body: request,
});
return response.ok
? updateUserAction.success(props, undefined)
: updateUserAction.error(props, response);
}
4 changes: 4 additions & 0 deletions client/src/app/+state/users/users.actions.ts
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export { addUserAction } from './actions/add-user.action';
export { loadUserLoginTokenAction } from './actions/load-user-login-token.action';
export { loadUsersAction } from './actions/load-users.action';
export { removeUserAction } from './actions/remove-user.action';
export { updateUserAction } from './actions/update-user.action';
12 changes: 11 additions & 1 deletion client/src/app/+state/users/users.effects.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { addUserEffects } from './actions/add-user.action';
import { loadUserLoginTokenEffects } from './actions/load-user-login-token.action';
import { loadUsersEffects } from './actions/load-users.action';
import { removeUserEffects } from './actions/remove-user.action';
import { updateUserEffects } from './actions/update-user.action';
import { Effects } from '../utils';

export const usersFeatureEffects: Effects[] = [loadUsersEffects];
export const usersFeatureEffects: Effects[] = [
addUserEffects,
loadUserLoginTokenEffects,
loadUsersEffects,
removeUserEffects,
updateUserEffects,
];
10 changes: 9 additions & 1 deletion client/src/app/+state/users/users.reducer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { createReducer } from '@ngrx/store';

import { addUserReducers } from './actions/add-user.action';
import { loadUserLoginTokenReducers } from './actions/load-user-login-token.action';
import { loadUsersReducers } from './actions/load-users.action';
import { removeUserReducers } from './actions/remove-user.action';
import { updateUserReducers } from './actions/update-user.action';
import { UsersFeatureState, initialUsersFeatureState } from './users.state';

export const usersReducer = createReducer<UsersFeatureState>(
initialUsersFeatureState,

...loadUsersReducers
...addUserReducers,
...loadUserLoginTokenReducers,
...loadUsersReducers,
...removeUserReducers,
...updateUserReducers
);
6 changes: 6 additions & 0 deletions client/src/app/+state/users/users.selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ export function selectUsersActionState(action: keyof UsersFeatureState['actionSt
export function selectUser(id: string) {
return createDistinctSelector(selectUsersFeature, state => state.entities[id]);
}

export function selectUserLoginToken(id: string | null | undefined) {
return createDistinctSelector(selectUsersFeature, state =>
id ? state.entities[id]?.loginToken : undefined
);
}
8 changes: 8 additions & 0 deletions client/src/app/+state/users/users.state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { ActionState, initialActionState } from '../action-state';
export type UsersFeatureState = EntityState<User> & {
actionStates: {
load: ActionState;
add: ActionState;
update: ActionState;
remove: ActionState;
loadLoginToken: ActionState;
};
};

Expand All @@ -17,5 +21,9 @@ export const userEntityAdapter = createEntityAdapter<User>({
export const initialUsersFeatureState: UsersFeatureState = userEntityAdapter.getInitialState({
actionStates: {
load: initialActionState,
add: initialActionState,
update: initialActionState,
remove: initialActionState,
loadLoginToken: initialActionState,
},
});
2 changes: 1 addition & 1 deletion client/src/app/components/maps/maps.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<p-messages severity="error">
<ng-template pTemplate>
<span class="i-[mdi--close-circle-outline] mr-2"></span>
<span>{{ translations.maps_loadError() }} {{ translations.shared_tryAgainLater() }}</span>
<span>{{ translations.maps_error_load() }} {{ translations.shared_tryAgainLater() }}</span>
</ng-template>
</p-messages>
} @else {
Expand Down
24 changes: 7 additions & 17 deletions client/src/app/components/maps/maps.component.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { Actions, ofType } from '@ngrx/effects';
Expand All @@ -30,6 +23,7 @@ import { interpolate } from '../../directives/interpolate.pipe';
import { MinigolfMap } from '../../models/parsed-models';
import { TranslateService } from '../../services/translate.service';
import { notNullish } from '../../utils/common.utils';
import { selectSignal } from '../../utils/ngrx.utils';

function mapMatchesFilter(
map: MinigolfMap | undefined,
Expand All @@ -55,13 +49,13 @@ function mapMatchesFilter(
styleUrl: './maps.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MapsComponent implements OnInit {
export class MapsComponent {
private readonly _store = inject(Store);
private readonly _allMaps = this._store.selectSignal(mapSelectors.selectAll);
private readonly _allMaps = selectSignal(mapSelectors.selectAll);
private readonly _confirmationService = inject(ConfirmationService);
private readonly _messageService = inject(MessageService);

private readonly _actionState = this._store.selectSignal(selectMapsActionState('load'));
private readonly _actionState = selectSignal(selectMapsActionState('load'));

protected readonly translations = inject(TranslateService).translations;
protected readonly filter = signal('');
Expand All @@ -70,6 +64,8 @@ export class MapsComponent implements OnInit {
protected readonly hasFailed = computed(() => hasActionFailed(this._actionState()));

constructor() {
this._store.dispatch(loadMapsAction({ reload: false }));

const action$ = inject(Actions);
action$
.pipe(ofType(removeMapAction.error), takeUntilDestroyed())
Expand All @@ -80,12 +76,6 @@ export class MapsComponent implements OnInit {
);
}

public ngOnInit(): void {
this._store.dispatch(loadMapsAction({ reload: false }));
}

protected trackByMapId = (_: number, map: MinigolfMap) => map.id;

protected deleteMap(map: MinigolfMap) {
this._confirmationService.confirm({
header: this.translations.maps_deleteDialog_title(),
Expand Down
Loading

0 comments on commit 041aec8

Please sign in to comment.