Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/app/features/moderation/collection-moderation.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { provideStates } from '@ngxs/store';
import { Routes } from '@angular/router';

import { CollectionsModerationState } from '@osf/features/moderation/store/collections-moderation';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum';
import { ActivityLogsState } from '@shared/stores/activity-logs';
import { CollectionsState } from '@shared/stores/collections';

import { ModeratorsState } from './store/moderators';
import { ProviderSubscriptionsState } from './store/provider-subscriptions';
import { CollectionModerationTab } from './enums';

export const collectionModerationRoutes: Routes = [
Expand Down Expand Up @@ -46,7 +47,8 @@ export const collectionModerationRoutes: Routes = [
import('./components/notification-settings/notification-settings.component').then(
(m) => m.NotificationSettingsComponent
),
data: { tab: CollectionModerationTab.Settings },
data: { tab: CollectionModerationTab.Settings, resourceType: CurrentResourceType.Collections },
providers: [provideStates([ProviderSubscriptionsState])],
},
],
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,42 @@
<p>
<span>{{ 'moderation.settingsMessage' | translate }}</span>
<a class="ml-1 font-bold cursor-pointer" routerLink="/settings/notifications">
<h2>{{ 'moderation.notificationPreferences.title' | translate }}</h2>
<p class="mt-4">
<span>{{ 'moderation.notificationPreferences.note' | translate }}</span>
<a class="ml-1 font-bold" routerLink="/settings/notifications">
{{ 'moderation.userSettings' | translate }}
</a>
</p>

@if (!isLoading()) {
<section class="notification-configuration mt-4" [formGroup]="form">
@for (sub of subscriptions(); track sub.id) {
<label [for]="'subscription-' + sub.id">
{{ 'moderation.notificationPreferences.items.' + sub.event | translate }}
</label>

<p-select
[inputId]="'subscription-' + sub.id"
[formControlName]="sub.id"
class="dropdown"
[options]="frequencyOptions"
optionLabel="label"
optionValue="value"
(onChange)="onFrequencyChange(sub, $event.value)"
>
<ng-template #selectedItem let-selectedOption>
{{ selectedOption.label | translate }}
</ng-template>

<ng-template #item let-item>
{{ item.label | translate }}
</ng-template>
</p-select>
}
</section>
} @else {
<section class="notification-configuration">
@for (_ of [1, 2]; track $index) {
<p-skeleton width="20rem" height="2rem"></p-skeleton>
<p-skeleton class="dropdown" height="3rem"></p-skeleton>
}
</section>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@use "styles/variables" as var;

.notification-configuration {
display: grid;
gap: 12px;
align-items: center;
grid-template-columns: 0.5fr 2fr;

.dropdown {
width: 50%;
}

@media (max-width: var.$breakpoint-sm) {
grid-template-columns: 1fr;
row-gap: 0;

.dropdown {
width: 100%;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,137 @@
import { Store } from '@ngxs/store';

import { MockProvider } from 'ng-mocks';

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';

import { SUBSCRIPTION_FREQUENCY_OPTIONS } from '@osf/shared/constants/subscription-options.const';
import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum';
import { SubscriptionEvent } from '@osf/shared/enums/subscriptions/subscription-event.enum';
import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum';
import { NotificationSubscription } from '@osf/shared/models/notifications/notification-subscription.model';
import { ToastService } from '@osf/shared/services/toast.service';

import {
GetProviderSubscriptions,
ProviderSubscriptionsSelectors,
UpdateProviderSubscription,
} from '../../store/provider-subscriptions';

import { NotificationSettingsComponent } from './notification-settings.component';

import { ToastServiceMock } from '@testing/mocks/toast.service.mock';
import { OSFTestingModule } from '@testing/osf.testing.module';
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
import { provideMockStore } from '@testing/providers/store-provider.mock';

const MOCK_PROVIDER_SUBSCRIPTIONS: NotificationSubscription[] = [
{
id: 'sub-1',
event: SubscriptionEvent.ProviderNewPendingSubmissions,
frequency: SubscriptionFrequency.Instant,
},
{
id: 'sub-2',
event: SubscriptionEvent.ProviderNewPendingWithdrawRequests,
frequency: SubscriptionFrequency.Never,
},
];

async function createComponent(resourceType: CurrentResourceType, providerId = 'test-provider-123') {
const mockActivatedRoute = ActivatedRouteMockBuilder.create()
.withParams({ providerId })
.withData({ resourceType })
.build();

await TestBed.configureTestingModule({
imports: [NotificationSettingsComponent, OSFTestingModule],
providers: [
MockProvider(ActivatedRoute, mockActivatedRoute),
ToastServiceMock,
provideMockStore({
signals: [
{ selector: ProviderSubscriptionsSelectors.getSubscriptions, value: MOCK_PROVIDER_SUBSCRIPTIONS },
{ selector: ProviderSubscriptionsSelectors.isLoading, value: false },
],
}),
],
}).compileComponents();

const fixture = TestBed.createComponent(NotificationSettingsComponent);
const component = fixture.componentInstance;
const toastService = TestBed.inject(ToastService) as jest.Mocked<ToastService>;
const store = TestBed.inject(Store);

return { fixture, component, toastService, store };
}

describe('NotificationSettingsComponent', () => {
let component: NotificationSettingsComponent;
let fixture: ComponentFixture<NotificationSettingsComponent>;
let toastService: jest.Mocked<ToastService>;
let store: Store;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NotificationSettingsComponent, OSFTestingModule],
}).compileComponents();
const mockProviderId = 'test-provider-123';

fixture = TestBed.createComponent(NotificationSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
beforeEach(async () => {
({ fixture, component, toastService, store } = await createComponent(
CurrentResourceType.Preprints,
mockProviderId
));
});

it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});

it('should read providerId and resourceType from route', () => {
fixture.detectChanges();
expect(component.providerId()).toBe(mockProviderId);
expect(component.resourceType()).toBe(CurrentResourceType.Preprints);
});

it('should dispatch GetProviderSubscriptions on init', () => {
fixture.detectChanges();
expect(store.dispatch as jest.Mock).toHaveBeenCalledWith(
new GetProviderSubscriptions(CurrentResourceType.Preprints, mockProviderId)
);
});

it('should dispatch UpdateProviderSubscription and show toast on frequency change', () => {
fixture.detectChanges();

component.onFrequencyChange(MOCK_PROVIDER_SUBSCRIPTIONS[0], SubscriptionFrequency.Daily);

expect(store.dispatch as jest.Mock).toHaveBeenCalledWith(
new UpdateProviderSubscription({
providerType: CurrentResourceType.Preprints,
providerId: mockProviderId,
subscriptionId: 'sub-1',
frequency: SubscriptionFrequency.Daily,
})
);
expect(toastService.showSuccess).toHaveBeenCalledWith('moderation.notificationPreferences.successUpdate');
});

it('should not dispatch UpdateProviderSubscription if frequency is unchanged', () => {
fixture.detectChanges();

component.onFrequencyChange(MOCK_PROVIDER_SUBSCRIPTIONS[0], SubscriptionFrequency.Instant);

expect(store.dispatch as jest.Mock).not.toHaveBeenCalledWith(expect.any(UpdateProviderSubscription));
});

it('should expose frequencyOptions from SUBSCRIPTION_FREQUENCY_OPTIONS', () => {
expect(component.frequencyOptions).toEqual(SUBSCRIPTION_FREQUENCY_OPTIONS);
});

it('should populate form controls when subscriptions load', () => {
fixture.detectChanges();
expect(component.form.contains('sub-1')).toBe(true);
expect(component.form.contains('sub-2')).toBe(true);
expect(component.form.get('sub-1')?.value).toBe(SubscriptionFrequency.Instant);
expect(component.form.get('sub-2')?.value).toBe(SubscriptionFrequency.Never);
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,102 @@
import { createDispatchMap, select } from '@ngxs/store';

import { TranslatePipe } from '@ngx-translate/core';

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Select } from 'primeng/select';
import { Skeleton } from 'primeng/skeleton';

import { of } from 'rxjs';
import { map } from 'rxjs/operators';

import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, Signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormBuilder, FormControl, FormRecord, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';

import { SUBSCRIPTION_FREQUENCY_OPTIONS } from '@osf/shared/constants/subscription-options.const';
import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum';
import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum';
import { NotificationSubscription } from '@osf/shared/models/notifications/notification-subscription.model';
import { ToastService } from '@osf/shared/services/toast.service';

import {
GetProviderSubscriptions,
ProviderSubscriptionsSelectors,
UpdateProviderSubscription,
} from '../../store/provider-subscriptions';

@Component({
selector: 'osf-notification-settings',
imports: [TranslatePipe, RouterLink],
imports: [TranslatePipe, RouterLink, ReactiveFormsModule, Select, Skeleton],
templateUrl: './notification-settings.component.html',
styleUrl: './notification-settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotificationSettingsComponent {}
export class NotificationSettingsComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly fb = inject(FormBuilder);
private readonly toastService = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);

readonly providerId = toSignal(
this.route.parent?.params.pipe(map((params) => params['providerId'])) ?? of(undefined)
);
readonly resourceType: Signal<CurrentResourceType | undefined> = toSignal(
this.route.data.pipe(map((params) => params['resourceType']))
);

subscriptions = select(ProviderSubscriptionsSelectors.getSubscriptions);
isLoading = select(ProviderSubscriptionsSelectors.isLoading);

readonly form = new FormRecord<FormControl<SubscriptionFrequency>>({});

readonly frequencyOptions = SUBSCRIPTION_FREQUENCY_OPTIONS;

private readonly actions = createDispatchMap({
getProviderSubscriptions: GetProviderSubscriptions,
updateProviderSubscription: UpdateProviderSubscription,
});

constructor() {
effect(() => {
const subs = this.subscriptions();
subs.forEach((sub) => {
const control = this.form.controls[sub.id];
if (!control) {
this.form.addControl(sub.id, this.fb.control(sub.frequency, { nonNullable: true }), { emitEvent: false });
return;
}

if (control.value !== sub.frequency) {
control.setValue(sub.frequency, { emitEvent: false });
}
});
});
}

ngOnInit(): void {
const providerType = this.resourceType();
const providerId = this.providerId();
if (providerType && providerId) {
this.actions.getProviderSubscriptions(providerType, providerId);
}
}

onFrequencyChange(sub: NotificationSubscription, frequency: SubscriptionFrequency): void {
if (sub.frequency === frequency) return;

const providerType = this.resourceType();
const providerId = this.providerId();
if (!providerType || !providerId) return;

this.actions
.updateProviderSubscription({
providerType,
providerId,
subscriptionId: sub.id,
frequency,
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.toastService.showSuccess('moderation.notificationPreferences.successUpdate'));
}
}
6 changes: 4 additions & 2 deletions src/app/features/moderation/preprint-moderation.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { provideStates } from '@ngxs/store';

import { Routes } from '@angular/router';

import { ResourceType } from '@osf/shared/enums/resource-type.enum';
import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum';

import { ModeratorsState } from './store/moderators';
import { PreprintModerationState } from './store/preprint-moderation';
import { ProviderSubscriptionsState } from './store/provider-subscriptions';
import { PreprintModerationTab } from './enums';

export const preprintModerationRoutes: Routes = [
Expand Down Expand Up @@ -51,7 +52,8 @@ export const preprintModerationRoutes: Routes = [
import('./components/notification-settings/notification-settings.component').then(
(m) => m.NotificationSettingsComponent
),
data: { tab: PreprintModerationTab.Notifications },
data: { tab: PreprintModerationTab.Notifications, resourceType: CurrentResourceType.Preprints },
providers: [provideStates([ProviderSubscriptionsState])],
},
{
path: 'settings',
Expand Down
10 changes: 7 additions & 3 deletions src/app/features/moderation/registry-moderation.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { provideStates } from '@ngxs/store';

import { Routes } from '@angular/router';

import { ResourceType } from '@osf/shared/enums/resource-type.enum';
import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum';

import { ModeratorsState } from './store/moderators';
import { ProviderSubscriptionsState } from './store/provider-subscriptions';
import { RegistryModerationState } from './store/registry-moderation';
import { RegistryModerationTab } from './enums';

Expand Down Expand Up @@ -48,8 +49,11 @@ export const registryModerationRoutes: Routes = [
{
path: 'settings',
loadComponent: () =>
import('./components/registry-settings/registry-settings.component').then((m) => m.RegistrySettingsComponent),
data: { tab: RegistryModerationTab.Settings },
import('./components/notification-settings/notification-settings.component').then(
(m) => m.NotificationSettingsComponent
),
data: { tab: RegistryModerationTab.Settings, resourceType: CurrentResourceType.Registrations },
providers: [provideStates([ProviderSubscriptionsState])],
},
],
},
Expand Down
1 change: 1 addition & 0 deletions src/app/features/moderation/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { ModeratorsService } from './moderators.service';
export { PreprintModerationService } from './preprint-moderation.service';
export { ProviderSubscriptionService } from './provider-subscription.service';
export { RegistryModerationService } from './registry-moderation.service';
Loading
Loading