Skip to content

Commit f806275

Browse files
authored
fix(ENG-9810): wire up provider subscription notification settings (#928)
- Ticket: [ENG-9810] ## Summary of Changes Moderators of Collections, Registries, and Preprints need to control how they receive notifications about moderation activity (new pending submissions, withdrawal requests) — instant, daily, or none. Previously, the Settings/Notifications tabs in the moderation pages were empty placeholders that just pointed users away to their global user settings. That was insufficient because these are provider-level subscriptions (scoped to a specific provider), not global ones. The changes wire up the real OSF API (/v2/providers/{type}/{id}/subscriptions/) so each moderation portal's notification tab now: 1. Loads the actual per-provider subscription preferences for the logged-in moderator 2. Lets them change the frequency via dropdowns, saved immediately via PATCH
1 parent c6b3b45 commit f806275

17 files changed

Lines changed: 497 additions & 21 deletions

src/app/features/moderation/collection-moderation.routes.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { provideStates } from '@ngxs/store';
33
import { Routes } from '@angular/router';
44

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

1010
import { ModeratorsState } from './store/moderators';
11+
import { ProviderSubscriptionsState } from './store/provider-subscriptions';
1112
import { CollectionModerationTab } from './enums';
1213

1314
export const collectionModerationRoutes: Routes = [
@@ -46,7 +47,8 @@ export const collectionModerationRoutes: Routes = [
4647
import('./components/notification-settings/notification-settings.component').then(
4748
(m) => m.NotificationSettingsComponent
4849
),
49-
data: { tab: CollectionModerationTab.Settings },
50+
data: { tab: CollectionModerationTab.Settings, resourceType: CurrentResourceType.Collections },
51+
providers: [provideStates([ProviderSubscriptionsState])],
5052
},
5153
],
5254
},
Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,42 @@
1-
<p>
2-
<span>{{ 'moderation.settingsMessage' | translate }}</span>
3-
<a class="ml-1 font-bold cursor-pointer" routerLink="/settings/notifications">
1+
<h2>{{ 'moderation.notificationPreferences.title' | translate }}</h2>
2+
<p class="mt-4">
3+
<span>{{ 'moderation.notificationPreferences.note' | translate }}</span>
4+
<a class="ml-1 font-bold" routerLink="/settings/notifications">
45
{{ 'moderation.userSettings' | translate }}
56
</a>
67
</p>
8+
9+
@if (!isLoading()) {
10+
<section class="notification-configuration mt-4" [formGroup]="form">
11+
@for (sub of subscriptions(); track sub.id) {
12+
<label [for]="'subscription-' + sub.id">
13+
{{ 'moderation.notificationPreferences.items.' + sub.event | translate }}
14+
</label>
15+
16+
<p-select
17+
[inputId]="'subscription-' + sub.id"
18+
[formControlName]="sub.id"
19+
class="dropdown"
20+
[options]="frequencyOptions"
21+
optionLabel="label"
22+
optionValue="value"
23+
(onChange)="onFrequencyChange(sub, $event.value)"
24+
>
25+
<ng-template #selectedItem let-selectedOption>
26+
{{ selectedOption.label | translate }}
27+
</ng-template>
28+
29+
<ng-template #item let-item>
30+
{{ item.label | translate }}
31+
</ng-template>
32+
</p-select>
33+
}
34+
</section>
35+
} @else {
36+
<section class="notification-configuration">
37+
@for (_ of [1, 2]; track $index) {
38+
<p-skeleton width="20rem" height="2rem"></p-skeleton>
39+
<p-skeleton class="dropdown" height="3rem"></p-skeleton>
40+
}
41+
</section>
42+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
@use "styles/variables" as var;
2+
3+
.notification-configuration {
4+
display: grid;
5+
gap: 12px;
6+
align-items: center;
7+
grid-template-columns: 0.5fr 2fr;
8+
9+
.dropdown {
10+
width: 50%;
11+
}
12+
13+
@media (max-width: var.$breakpoint-sm) {
14+
grid-template-columns: 1fr;
15+
row-gap: 0;
16+
17+
.dropdown {
18+
width: 100%;
19+
}
20+
}
21+
}
Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,137 @@
1+
import { Store } from '@ngxs/store';
2+
3+
import { MockProvider } from 'ng-mocks';
4+
15
import { ComponentFixture, TestBed } from '@angular/core/testing';
6+
import { ActivatedRoute } from '@angular/router';
7+
8+
import { SUBSCRIPTION_FREQUENCY_OPTIONS } from '@osf/shared/constants/subscription-options.const';
9+
import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum';
10+
import { SubscriptionEvent } from '@osf/shared/enums/subscriptions/subscription-event.enum';
11+
import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum';
12+
import { NotificationSubscription } from '@osf/shared/models/notifications/notification-subscription.model';
13+
import { ToastService } from '@osf/shared/services/toast.service';
14+
15+
import {
16+
GetProviderSubscriptions,
17+
ProviderSubscriptionsSelectors,
18+
UpdateProviderSubscription,
19+
} from '../../store/provider-subscriptions';
220

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

23+
import { ToastServiceMock } from '@testing/mocks/toast.service.mock';
524
import { OSFTestingModule } from '@testing/osf.testing.module';
25+
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
26+
import { provideMockStore } from '@testing/providers/store-provider.mock';
27+
28+
const MOCK_PROVIDER_SUBSCRIPTIONS: NotificationSubscription[] = [
29+
{
30+
id: 'sub-1',
31+
event: SubscriptionEvent.ProviderNewPendingSubmissions,
32+
frequency: SubscriptionFrequency.Instant,
33+
},
34+
{
35+
id: 'sub-2',
36+
event: SubscriptionEvent.ProviderNewPendingWithdrawRequests,
37+
frequency: SubscriptionFrequency.Never,
38+
},
39+
];
40+
41+
async function createComponent(resourceType: CurrentResourceType, providerId = 'test-provider-123') {
42+
const mockActivatedRoute = ActivatedRouteMockBuilder.create()
43+
.withParams({ providerId })
44+
.withData({ resourceType })
45+
.build();
46+
47+
await TestBed.configureTestingModule({
48+
imports: [NotificationSettingsComponent, OSFTestingModule],
49+
providers: [
50+
MockProvider(ActivatedRoute, mockActivatedRoute),
51+
ToastServiceMock,
52+
provideMockStore({
53+
signals: [
54+
{ selector: ProviderSubscriptionsSelectors.getSubscriptions, value: MOCK_PROVIDER_SUBSCRIPTIONS },
55+
{ selector: ProviderSubscriptionsSelectors.isLoading, value: false },
56+
],
57+
}),
58+
],
59+
}).compileComponents();
60+
61+
const fixture = TestBed.createComponent(NotificationSettingsComponent);
62+
const component = fixture.componentInstance;
63+
const toastService = TestBed.inject(ToastService) as jest.Mocked<ToastService>;
64+
const store = TestBed.inject(Store);
65+
66+
return { fixture, component, toastService, store };
67+
}
668

769
describe('NotificationSettingsComponent', () => {
870
let component: NotificationSettingsComponent;
971
let fixture: ComponentFixture<NotificationSettingsComponent>;
72+
let toastService: jest.Mocked<ToastService>;
73+
let store: Store;
1074

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

16-
fixture = TestBed.createComponent(NotificationSettingsComponent);
17-
component = fixture.componentInstance;
18-
fixture.detectChanges();
77+
beforeEach(async () => {
78+
({ fixture, component, toastService, store } = await createComponent(
79+
CurrentResourceType.Preprints,
80+
mockProviderId
81+
));
1982
});
2083

2184
it('should create', () => {
85+
fixture.detectChanges();
2286
expect(component).toBeTruthy();
2387
});
88+
89+
it('should read providerId and resourceType from route', () => {
90+
fixture.detectChanges();
91+
expect(component.providerId()).toBe(mockProviderId);
92+
expect(component.resourceType()).toBe(CurrentResourceType.Preprints);
93+
});
94+
95+
it('should dispatch GetProviderSubscriptions on init', () => {
96+
fixture.detectChanges();
97+
expect(store.dispatch as jest.Mock).toHaveBeenCalledWith(
98+
new GetProviderSubscriptions(CurrentResourceType.Preprints, mockProviderId)
99+
);
100+
});
101+
102+
it('should dispatch UpdateProviderSubscription and show toast on frequency change', () => {
103+
fixture.detectChanges();
104+
105+
component.onFrequencyChange(MOCK_PROVIDER_SUBSCRIPTIONS[0], SubscriptionFrequency.Daily);
106+
107+
expect(store.dispatch as jest.Mock).toHaveBeenCalledWith(
108+
new UpdateProviderSubscription({
109+
providerType: CurrentResourceType.Preprints,
110+
providerId: mockProviderId,
111+
subscriptionId: 'sub-1',
112+
frequency: SubscriptionFrequency.Daily,
113+
})
114+
);
115+
expect(toastService.showSuccess).toHaveBeenCalledWith('moderation.notificationPreferences.successUpdate');
116+
});
117+
118+
it('should not dispatch UpdateProviderSubscription if frequency is unchanged', () => {
119+
fixture.detectChanges();
120+
121+
component.onFrequencyChange(MOCK_PROVIDER_SUBSCRIPTIONS[0], SubscriptionFrequency.Instant);
122+
123+
expect(store.dispatch as jest.Mock).not.toHaveBeenCalledWith(expect.any(UpdateProviderSubscription));
124+
});
125+
126+
it('should expose frequencyOptions from SUBSCRIPTION_FREQUENCY_OPTIONS', () => {
127+
expect(component.frequencyOptions).toEqual(SUBSCRIPTION_FREQUENCY_OPTIONS);
128+
});
129+
130+
it('should populate form controls when subscriptions load', () => {
131+
fixture.detectChanges();
132+
expect(component.form.contains('sub-1')).toBe(true);
133+
expect(component.form.contains('sub-2')).toBe(true);
134+
expect(component.form.get('sub-1')?.value).toBe(SubscriptionFrequency.Instant);
135+
expect(component.form.get('sub-2')?.value).toBe(SubscriptionFrequency.Never);
136+
});
24137
});
Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,102 @@
1+
import { createDispatchMap, select } from '@ngxs/store';
2+
13
import { TranslatePipe } from '@ngx-translate/core';
24

3-
import { ChangeDetectionStrategy, Component } from '@angular/core';
4-
import { RouterLink } from '@angular/router';
5+
import { Select } from 'primeng/select';
6+
import { Skeleton } from 'primeng/skeleton';
7+
8+
import { of } from 'rxjs';
9+
import { map } from 'rxjs/operators';
10+
11+
import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, Signal } from '@angular/core';
12+
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
13+
import { FormBuilder, FormControl, FormRecord, ReactiveFormsModule } from '@angular/forms';
14+
import { ActivatedRoute, RouterLink } from '@angular/router';
15+
16+
import { SUBSCRIPTION_FREQUENCY_OPTIONS } from '@osf/shared/constants/subscription-options.const';
17+
import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum';
18+
import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum';
19+
import { NotificationSubscription } from '@osf/shared/models/notifications/notification-subscription.model';
20+
import { ToastService } from '@osf/shared/services/toast.service';
21+
22+
import {
23+
GetProviderSubscriptions,
24+
ProviderSubscriptionsSelectors,
25+
UpdateProviderSubscription,
26+
} from '../../store/provider-subscriptions';
527

628
@Component({
729
selector: 'osf-notification-settings',
8-
imports: [TranslatePipe, RouterLink],
30+
imports: [TranslatePipe, RouterLink, ReactiveFormsModule, Select, Skeleton],
931
templateUrl: './notification-settings.component.html',
1032
styleUrl: './notification-settings.component.scss',
1133
changeDetection: ChangeDetectionStrategy.OnPush,
1234
})
13-
export class NotificationSettingsComponent {}
35+
export class NotificationSettingsComponent implements OnInit {
36+
private readonly route = inject(ActivatedRoute);
37+
private readonly fb = inject(FormBuilder);
38+
private readonly toastService = inject(ToastService);
39+
private readonly destroyRef = inject(DestroyRef);
40+
41+
readonly providerId = toSignal(
42+
this.route.parent?.params.pipe(map((params) => params['providerId'])) ?? of(undefined)
43+
);
44+
readonly resourceType: Signal<CurrentResourceType | undefined> = toSignal(
45+
this.route.data.pipe(map((params) => params['resourceType']))
46+
);
47+
48+
subscriptions = select(ProviderSubscriptionsSelectors.getSubscriptions);
49+
isLoading = select(ProviderSubscriptionsSelectors.isLoading);
50+
51+
readonly form = new FormRecord<FormControl<SubscriptionFrequency>>({});
52+
53+
readonly frequencyOptions = SUBSCRIPTION_FREQUENCY_OPTIONS;
54+
55+
private readonly actions = createDispatchMap({
56+
getProviderSubscriptions: GetProviderSubscriptions,
57+
updateProviderSubscription: UpdateProviderSubscription,
58+
});
59+
60+
constructor() {
61+
effect(() => {
62+
const subs = this.subscriptions();
63+
subs.forEach((sub) => {
64+
const control = this.form.controls[sub.id];
65+
if (!control) {
66+
this.form.addControl(sub.id, this.fb.control(sub.frequency, { nonNullable: true }), { emitEvent: false });
67+
return;
68+
}
69+
70+
if (control.value !== sub.frequency) {
71+
control.setValue(sub.frequency, { emitEvent: false });
72+
}
73+
});
74+
});
75+
}
76+
77+
ngOnInit(): void {
78+
const providerType = this.resourceType();
79+
const providerId = this.providerId();
80+
if (providerType && providerId) {
81+
this.actions.getProviderSubscriptions(providerType, providerId);
82+
}
83+
}
84+
85+
onFrequencyChange(sub: NotificationSubscription, frequency: SubscriptionFrequency): void {
86+
if (sub.frequency === frequency) return;
87+
88+
const providerType = this.resourceType();
89+
const providerId = this.providerId();
90+
if (!providerType || !providerId) return;
91+
92+
this.actions
93+
.updateProviderSubscription({
94+
providerType,
95+
providerId,
96+
subscriptionId: sub.id,
97+
frequency,
98+
})
99+
.pipe(takeUntilDestroyed(this.destroyRef))
100+
.subscribe(() => this.toastService.showSuccess('moderation.notificationPreferences.successUpdate'));
101+
}
102+
}

src/app/features/moderation/preprint-moderation.routes.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { provideStates } from '@ngxs/store';
22

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

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

77
import { ModeratorsState } from './store/moderators';
88
import { PreprintModerationState } from './store/preprint-moderation';
9+
import { ProviderSubscriptionsState } from './store/provider-subscriptions';
910
import { PreprintModerationTab } from './enums';
1011

1112
export const preprintModerationRoutes: Routes = [
@@ -51,7 +52,8 @@ export const preprintModerationRoutes: Routes = [
5152
import('./components/notification-settings/notification-settings.component').then(
5253
(m) => m.NotificationSettingsComponent
5354
),
54-
data: { tab: PreprintModerationTab.Notifications },
55+
data: { tab: PreprintModerationTab.Notifications, resourceType: CurrentResourceType.Preprints },
56+
providers: [provideStates([ProviderSubscriptionsState])],
5557
},
5658
{
5759
path: 'settings',

src/app/features/moderation/registry-moderation.routes.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { provideStates } from '@ngxs/store';
22

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

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

77
import { ModeratorsState } from './store/moderators';
8+
import { ProviderSubscriptionsState } from './store/provider-subscriptions';
89
import { RegistryModerationState } from './store/registry-moderation';
910
import { RegistryModerationTab } from './enums';
1011

@@ -48,8 +49,11 @@ export const registryModerationRoutes: Routes = [
4849
{
4950
path: 'settings',
5051
loadComponent: () =>
51-
import('./components/registry-settings/registry-settings.component').then((m) => m.RegistrySettingsComponent),
52-
data: { tab: RegistryModerationTab.Settings },
52+
import('./components/notification-settings/notification-settings.component').then(
53+
(m) => m.NotificationSettingsComponent
54+
),
55+
data: { tab: RegistryModerationTab.Settings, resourceType: CurrentResourceType.Registrations },
56+
providers: [provideStates([ProviderSubscriptionsState])],
5357
},
5458
],
5559
},
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { ModeratorsService } from './moderators.service';
22
export { PreprintModerationService } from './preprint-moderation.service';
3+
export { ProviderSubscriptionService } from './provider-subscription.service';
34
export { RegistryModerationService } from './registry-moderation.service';

0 commit comments

Comments
 (0)