Skip to content

Commit

Permalink
feat: make sure two users are not editing instances at the same time
Browse files Browse the repository at this point in the history
Refs: #24
  • Loading branch information
MaSch0212 committed Jul 27, 2024
1 parent 7c2fb94 commit f08b025
Show file tree
Hide file tree
Showing 26 changed files with 2,387 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { inject } from '@angular/core';
import { on } from '@ngrx/store';
import { produce } from 'immer';
import { switchMap } from 'rxjs';

import { EventAdministrationService } from '../../../api/services';
import { AuthService } from '../../../services/auth.service';
import { createHttpAction, handleHttpAction, onHttpAction } from '../../action-state';
import { createFunctionalEffect } from '../../functional-effect';
import { Effects, Reducers } from '../../utils';
import { EVENTS_ACTION_SCOPE } from '../consts';
import { eventEntityAdapter, EventsFeatureState } from '../events.state';

export const setEditingEventInstancesAction = createHttpAction<
{ eventId: string; isEditing: boolean },
{ userIdEditingInstances: string | null }
>()(EVENTS_ACTION_SCOPE, 'Set Editing Event Instances');

export const setEditingEventInstancesReducers: Reducers<EventsFeatureState> = [
on(setEditingEventInstancesAction.success, (state, { props, response }) =>
eventEntityAdapter.mapOne(
{
id: props.eventId,
map: produce(draft => {
draft.userIdEditingInstances = response.userIdEditingInstances;
}),
},
state
)
),
handleHttpAction('setInstancesEditing', setEditingEventInstancesAction),
];

export const setEditingEventInstancesEffects: Effects = {
setEditingEventInstances$: createFunctionalEffect.dispatching(
(api = inject(EventAdministrationService), authService = inject(AuthService)) =>
onHttpAction(setEditingEventInstancesAction).pipe(
switchMap(({ props }) => setEditingEventInstances(api, props, authService))
)
),
};

async function setEditingEventInstances(
api: EventAdministrationService,
props: ReturnType<typeof setEditingEventInstancesAction>['props'],
authService: AuthService
) {
const response = await api.setEventInstancesEditing({
eventId: props.eventId,
body: { isEditing: props.isEditing },
});
return response.ok
? setEditingEventInstancesAction.success(props, {
userIdEditingInstances: props.isEditing ? (authService.user()?.id ?? null) : null,
})
: setEditingEventInstancesAction.error(props, response);
}
1 change: 1 addition & 0 deletions src/client/src/app/+state/events/events.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export { removeEventTimeslotAction } from './actions/remove-event-timeslot.actio
export { removeEventAction } from './actions/remove-event.action';
export { removePlayerFromPreconfigAction } from './actions/remove-player-from-preconfig.action';
export { resetEventsActionStateAction } from './actions/reset-events-action-state.action';
export { setEditingEventInstancesAction } from './actions/set-editing-event-instances.action';
export { setEventInstancesAction } from './actions/set-event-instances.action';
export { startEventAction } from './actions/start-event.action';
export { updateEventTimeslotAction } from './actions/update-event-timeslot.action';
Expand Down
15 changes: 15 additions & 0 deletions src/client/src/app/+state/events/events.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { removeEventPreconfigEffects } from './actions/remove-event-preconfig.ac
import { removeEventTimeslotEffects } from './actions/remove-event-timeslot.action';
import { removeEventAction, removeEventEffects } from './actions/remove-event.action';
import { removePlayerFromPreconfigEffects } from './actions/remove-player-from-preconfig.action';
import {
setEditingEventInstancesAction,
setEditingEventInstancesEffects,
} from './actions/set-editing-event-instances.action';
import { setEventInstancesEffects } from './actions/set-event-instances.action';
import { startEventEffects } from './actions/start-event.action';
import { updateEventTimeslotEffects } from './actions/update-event-timeslot.action';
Expand All @@ -36,6 +40,7 @@ export const eventsFeatureEffects: Effects[] = [
removeEventTimeslotEffects,
removeEventEffects,
removePlayerFromPreconfigEffects,
setEditingEventInstancesEffects,
setEventInstancesEffects,
startEventEffects,
updateEventEffects,
Expand Down Expand Up @@ -68,6 +73,16 @@ export const eventsFeatureEffects: Effects[] = [
map(event => eventTimeslotRegistrationChangedAction(event))
)
),
eventInstancesEditorChanged$: createFunctionalEffect.dispatching(() =>
inject(RealtimeEventsService).eventInstancesEditorChanged.pipe(
map(event =>
setEditingEventInstancesAction.success(
{ eventId: event.eventId, isEditing: !!event.userId },
{ userIdEditingInstances: event.userId ?? null }
)
)
)
),

onServerReconnected$: createFunctionalEffect.dispatching(() =>
inject(RealtimeEventsService).onReconnected$.pipe(
Expand Down
2 changes: 2 additions & 0 deletions src/client/src/app/+state/events/events.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { removeEventTimeslotReducers } from './actions/remove-event-timeslot.act
import { removeEventReducers } from './actions/remove-event.action';
import { removePlayerFromPreconfigReducers } from './actions/remove-player-from-preconfig.action';
import { resetEventsActionStateReducers } from './actions/reset-events-action-state.action';
import { setEditingEventInstancesReducers } from './actions/set-editing-event-instances.action';
import { setEventInstancesReducers } from './actions/set-event-instances.action';
import { startEventReducers } from './actions/start-event.action';
import { updateEventTimeslotReducers } from './actions/update-event-timeslot.action';
Expand All @@ -35,6 +36,7 @@ export const eventsReducer = createReducer<EventsFeatureState>(
...removeEventReducers,
...removePlayerFromPreconfigReducers,
...resetEventsActionStateReducers,
...setEditingEventInstancesReducers,
...setEventInstancesReducers,
...startEventReducers,
...updateEventTimeslotReducers,
Expand Down
10 changes: 8 additions & 2 deletions src/client/src/app/+state/events/events.selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ export function selectEventsActionState(action: keyof EventsFeatureState['action

export function selectEvent(id: string | null | undefined) {
return createDistinctSelector(selectEventsFeature, state =>
id ? state.entities[id] ?? null : null
id ? (state.entities[id] ?? null) : null
);
}

export function selectEventEditor(id: string | null | undefined) {
return createDistinctSelector(selectEventsFeature, state =>
id ? (state.entities[id]?.userIdEditingInstances ?? null) : null
);
}

Expand All @@ -33,7 +39,7 @@ export function selectEventTimeslot(
) {
return createDistinctSelector(selectEventsFeature, state =>
eventId && timeslotId
? state.entities[eventId]?.timeslots.find(x => x.id === timeslotId) ?? null
? (state.entities[eventId]?.timeslots.find(x => x.id === timeslotId) ?? null)
: null
);
}
2 changes: 2 additions & 0 deletions src/client/src/app/+state/events/events.state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type EventsFeatureState = EntityState<Event> & {
removeTimeslot: ActionState;
buildInstances: ActionState;
setInstances: ActionState;
setInstancesEditing: ActionState;
updateTimeslot: ActionState;
addPreconfig: ActionState;
removePreconfig: ActionState;
Expand All @@ -42,6 +43,7 @@ export const initialEventsFeatureState: EventsFeatureState = eventEntityAdapter.
removeTimeslot: initialActionState,
buildInstances: initialActionState,
setInstances: initialActionState,
setInstancesEditing: initialActionState,
updateTimeslot: initialActionState,
addPreconfig: initialActionState,
removePreconfig: initialActionState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ <h2 class="m-0 grow">{{ translations.events_facebookLink() }}</h2>
<div class="mt-4 flex flex-row items-center gap-2">
<h2 class="m-0 grow">{{ translations.events_timeslots() }}</h2>
<span
class="inline-block rounded-full bg-primary pr-2 text-center font-bold text-primary-text"
class="inline-block rounded-full bg-primary px-2 text-center font-bold text-primary-text"
>
<span class="i-[mdi--account] ml-2"></span> {{ playersAmount() }}
<span class="i-[mdi--account]"></span> {{ playersAmount() }}
</span>
@if (!event.startedAt && event.staged) {
<p-button
Expand Down Expand Up @@ -178,6 +178,14 @@ <h2 class="m-0 grow">{{ translations.events_groups() }}</h2>
}
</p-inputGroup>
</div>
@if (event.userIdEditingInstances) {
<div
class="self-end rounded-full bg-primary px-2 text-center font-bold text-primary-text"
>
<span class="i-[mdi--pencil]"></span>
{{ allUsers()[event.userIdEditingInstances]?.alias }}
</div>
}
<div class="flex shrink-0 flex-row overflow-auto text-xs">
@for (timeslot of timeslots(); track timeslot.id) {
<div class="flex w-1/3 min-w-28 shrink-0 flex-col gap-2 px-1">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { produce } from 'immer';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog';
import { InputGroupModule } from 'primeng/inputgroup';
import { ListboxModule } from 'primeng/listbox';
import { OverlayPanelModule } from 'primeng/overlaypanel';
import { filter } from 'rxjs';
import { filter, firstValueFrom, pairwise } from 'rxjs';

import { isActionBusy } from '../../../+state/action-state';
import { selectEventsActionState, setEventInstancesAction } from '../../../+state/events';
import { hasActionFailed, isActionBusy, isActionIdle } from '../../../+state/action-state';
import {
selectEventEditor,
selectEventsActionState,
setEditingEventInstancesAction,
setEventInstancesAction,
} from '../../../+state/events';
import { userSelectors } from '../../../+state/users';
import { Event, EventInstance, EventTimeslot } from '../../../models/parsed-models';
import { AuthService } from '../../../services/auth.service';
import { Logger } from '../../../services/logger.service';
import { TranslateService } from '../../../services/translate.service';
import { notNullish } from '../../../utils/common.utils';
Expand All @@ -40,6 +55,9 @@ type EventInstances = { timeslot: EventTimeslot; instances: EventInstance[] }[];
})
export class EventInstancesDialogComponent {
private readonly _store = inject(Store);
private readonly _messageService = inject(MessageService);
private readonly _confirmService = inject(ConfirmationService);
private readonly _authService = inject(AuthService);
protected readonly translations = inject(TranslateService).translations;

private readonly _removeItem: EventInstance = {
Expand All @@ -51,6 +69,9 @@ export class EventInstancesDialogComponent {

protected readonly visible = signal(false);
protected readonly event = signal<Event | null>(null);
protected readonly eventEditedBy = selectSignal(
computed(() => selectEventEditor(this.event()?.id))
);
protected readonly instances = signal<EventInstances>([]);
protected readonly allUsers = selectSignal(userSelectors.selectEntities);
protected readonly unassignedUsers = computed(() =>
Expand Down Expand Up @@ -102,6 +123,40 @@ export class EventInstancesDialogComponent {
protected readonly isBusy = computed(() => isActionBusy(this._actionState()));

constructor() {
toObservable(this.visible)
.pipe(
pairwise(),
takeUntilDestroyed(),
filter(([prev, curr]) => prev && !curr)
)
.subscribe(() => {
const event = this.event();
if (event && this.eventEditedBy() === this._authService.user()?.id) {
this._store.dispatch(
setEditingEventInstancesAction({ eventId: event.id, isEditing: false })
);
}
this.event.set(null);
this.instances.set([]);
});

effect(
() => {
const eventEditedBy = this.eventEditedBy();
if (eventEditedBy !== this._authService.user()?.id && untracked(() => this.visible())) {
this.visible.set(false);
const user = eventEditedBy ? this.allUsers()[eventEditedBy] : null;
this._confirmService.confirm({
message: this.translations.events_removeFromEditingBy(user),
rejectVisible: false,
acceptIcon: 'no-icon',
acceptLabel: this.translations.shared_ok(),
});
}
},
{ allowSignalWrites: true }
);

errorToastEffect(this.translations.events_error_changeGroups, this._actionState);

const actions$ = inject(Actions);
Expand All @@ -114,7 +169,41 @@ export class EventInstancesDialogComponent {
.subscribe(() => this.visible.set(false));
}

public open(event: Event) {
public async open(event: Event) {
if (event.userIdEditingInstances) {
const user = this.allUsers()[event.userIdEditingInstances];
const result = await new Promise<boolean>(resolve => {
this._confirmService.confirm({
icon: 'i-[mdi--account-lock]',
header: this.translations.events_userIsEditingAlreadyTitle(),
message: this.translations.events_userIsEditingAlready(user),
acceptLabel: this.translations.events_removeUserFromEditing(user),
acceptButtonStyleClass: 'p-button-danger',
rejectLabel: this.translations.shared_cancel(),
rejectButtonStyleClass: 'p-button-text',
accept: () => resolve(true),
reject: () => resolve(false),
});
});
if (!result) return;
}

this._store.dispatch(setEditingEventInstancesAction({ eventId: event.id, isEditing: true }));
const result = await firstValueFrom(
this._store
.select(selectEventsActionState('setInstancesEditing'))
.pipe(filter(x => isActionIdle(x)))
);
if (hasActionFailed(result)) {
this._messageService.add({
severity: 'error',
summary: this.translations.events_error_setEditingInstances(),
detail: this.translations.shared_tryAgainLater(),
life: 7500,
});
return;
}

this.event.set(event);
this.instances.set(
event.timeslots.map(timeslot => ({ timeslot, instances: timeslot.instances }))
Expand Down
7 changes: 6 additions & 1 deletion src/client/src/app/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@
"staged": "vorbereitet",
"facebookLink": "Facebook link",
"visitFacebookLink": "Öffne Facebook Event",
"userIsEditingAlreadyTitle": "Bearbeitungsmodus belegt",
"userIsEditingAlready": "<b>{{alias}}</b> bearbeitet bereits die Gruppen der Veranstaltung.<br>Die Gruppen können nur von einer Person gleichzeitig bearbeitet werden.<br><br>Bitte sprich dich mit {{alias}} ab. Im Zweifel kannst du {{alias}} auch aus dem Bearbeitungsmodus entfernen.<br><br><b>Wichtig: </b>Bitte beachte, dass dabei alle Änderungen von {{alias}} verloren gehen.",
"removeUserFromEditing": "{{alias}} aus dem Bearbeitungsmodus entfernen",
"removeFromEditingBy": "Du wurdest von {{alias}} aus dem Bearbeitungsmodus entfernt.",
"timeslot": {
"notFound": "Dieser Zeitslot existiert nicht.",
"preconfiguredGroups": "Vordefinierte Gruppen",
Expand Down Expand Up @@ -156,7 +160,8 @@
"deletePreconfig": "Fehler beim Löschen der vordefinierten Gruppe.",
"start": "Fehler beim Starten der Veranstaltung.",
"buildGroups": "Fehler beim Bilden der Gruppen.",
"changeGroups": "Fehler beim Ändern der Gruppen."
"changeGroups": "Fehler beim Ändern der Gruppen.",
"setEditingInstances": "Fehler beim beanspruchen der Bearbeitung der Gruppen."
}
},
"maps": {
Expand Down
7 changes: 6 additions & 1 deletion src/client/src/app/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@
"staged": "staged",
"facebookLink": "Facebook link",
"visitFacebookLink": "View Facebook Event",
"userIsEditingAlreadyTitle": "User is editing already",
"userIsEditingAlready": "<b>{{alias}}</b> is already editing the groups for the event.<br>The groups can only be edited by one person at a time.<br><br>Please consult with {{alias}}. If in doubt, you can also remove {{alias}} from the edit mode.<br><br><b>Important: </b>Please note that all changes made by {{alias}} will be lost.",
"removeUserFromEditing": "Remove {{alias}} from edit mode",
"removeFromEditingBy": "You have been removed from edit mode by {{alias}}.",
"timeslot": {
"notFound": "This timeslot does not exist.",
"preconfiguredGroups": "Preconfigured groups",
Expand Down Expand Up @@ -156,7 +160,8 @@
"deletePreconfig": "Failed to delete preconfigured group.",
"start": "Failed to start event.",
"buildGroups": "Failed to build groups.",
"changeGroups": "Failed to change groups."
"changeGroups": "Failed to change groups.",
"setEditingInstances": "Error when claiming the editing of groups."
}
},
"maps": {
Expand Down
4 changes: 4 additions & 0 deletions src/client/src/app/models/realtime-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export type EventTimeslotChangedRealtimeEvent = {
export type EventInstancesChangedRealtimeEvent = {
eventId: string;
};
export type EventInstancesEditorChangedEvent = {
eventId: string;
userId: string | null | undefined;
};
export type EventPreconfigurationChangedRealtimeEvent = {
eventId: string;
eventTimeslotId: string;
Expand Down
Loading

0 comments on commit f08b025

Please sign in to comment.