Skip to content

Commit 17cf217

Browse files
committed
feat(registration-recent-activity): add Recent Activity to registrations
1 parent 064a6b4 commit 17cf217

File tree

9 files changed

+234
-3
lines changed

9 files changed

+234
-3
lines changed

src/app/core/constants/nav-items.constant.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,13 @@ export const REGISTRATION_MENU_ITEMS: MenuItem[] = [
165165
visible: true,
166166
routerLinkActiveOptions: { exact: false },
167167
},
168+
{
169+
id: 'registration-recent-activity',
170+
label: 'navigation.recentActivity',
171+
routerLink: 'recent-activity',
172+
visible: true,
173+
routerLinkActiveOptions: { exact: true },
174+
},
168175
];
169176

170177
export const MENU_ITEMS: MenuItem[] = [
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<div class="activities p-3 flex flex-column gap-3">
2+
<h2 class="mb-2">{{ 'project.overview.recentActivity.title' | translate }}</h2>
3+
4+
@if (!isLoading()) {
5+
@if (formattedActivityLogs().length) {
6+
@for (activityLog of formattedActivityLogs(); track activityLog.id) {
7+
<div class="activities-activity flex justify-content-between gap-3 pb-2 align-items-center">
8+
<div [innerHTML]="activityLog.formattedActivity"></div>
9+
<p class="hidden activity-date sm:block text-right">
10+
{{ activityLog.date | date: 'MMM d, y hh:mm a' }}
11+
</p>
12+
</div>
13+
}
14+
} @else {
15+
<div class="text-center text-muted-color">
16+
{{ 'project.overview.recentActivity.noActivity' | translate }}
17+
</div>
18+
}
19+
20+
@if (totalCount() > pageSize) {
21+
<osf-custom-paginator
22+
[showFirstLastIcon]="true"
23+
[totalCount]="totalCount()"
24+
[rows]="pageSize"
25+
[first]="firstIndex()"
26+
(pageChanged)="onPageChange($event)"
27+
/>
28+
}
29+
} @else {
30+
<div class="flex flex-column gap-2">
31+
<p-skeleton width="100%" height="2rem" />
32+
<p-skeleton width="100%" height="2rem" />
33+
<p-skeleton width="100%" height="2rem" />
34+
<p-skeleton width="100%" height="2rem" />
35+
<p-skeleton width="100%" height="2rem" />
36+
</div>
37+
}
38+
</div>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
@use "/assets/styles/variables" as var;
2+
@use "/assets/styles/mixins" as mix;
3+
4+
.activities {
5+
border: 1px solid var.$grey-2;
6+
border-radius: mix.rem(12px);
7+
color: var.$dark-blue-1;
8+
9+
&-activity {
10+
border-bottom: 1px solid var.$grey-2;
11+
12+
.activity-date {
13+
width: 30%;
14+
}
15+
}
16+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { NgxsModule, Store } from '@ngxs/store';
2+
3+
import { ComponentFixture, TestBed } from '@angular/core/testing';
4+
import { ActivatedRoute } from '@angular/router';
5+
6+
import { ClearActivityLogsStore, GetRegistrationActivityLogs } from '@shared/stores/activity-logs';
7+
import { ActivityLogsState } from '@shared/stores/activity-logs/activity-logs.state';
8+
9+
import { RegistrationRecentActivityComponent } from './registration-recent-activity.component';
10+
11+
describe('RegistrationRecentActivityComponent', () => {
12+
let fixture: ComponentFixture<RegistrationRecentActivityComponent>;
13+
let store: Store;
14+
15+
beforeEach(async () => {
16+
await TestBed.configureTestingModule({
17+
imports: [NgxsModule.forRoot([ActivityLogsState]), RegistrationRecentActivityComponent],
18+
providers: [{ provide: ActivatedRoute, useValue: { snapshot: { params: { id: 'reg123' } }, parent: null } }],
19+
}).compileComponents();
20+
21+
store = TestBed.inject(Store);
22+
spyOn(store, 'dispatch').and.callThrough();
23+
24+
fixture = TestBed.createComponent(RegistrationRecentActivityComponent);
25+
fixture.detectChanges();
26+
});
27+
28+
it('creates and dispatches initial registration logs fetch', () => {
29+
expect(fixture.componentInstance).toBeTruthy();
30+
expect(store.dispatch).toHaveBeenCalledWith(jasmine.any(GetRegistrationActivityLogs));
31+
const action = (store.dispatch as jasmine.Spy).calls.mostRecent().args[0] as GetRegistrationActivityLogs;
32+
expect(action.registrationId).toBe('reg123');
33+
expect(action.page).toBe('1');
34+
});
35+
36+
it('dispatches on page change', () => {
37+
(store.dispatch as jasmine.Spy).calls.reset();
38+
fixture.componentInstance.onPageChange({ page: 2 } as any);
39+
expect(store.dispatch).toHaveBeenCalledWith(jasmine.any(GetRegistrationActivityLogs));
40+
const action = (store.dispatch as jasmine.Spy).calls.mostRecent().args[0] as GetRegistrationActivityLogs;
41+
expect(action.page).toBe('3');
42+
});
43+
44+
it('clears store on destroy', () => {
45+
(store.dispatch as jasmine.Spy).calls.reset();
46+
fixture.destroy();
47+
expect(store.dispatch).toHaveBeenCalledWith(jasmine.any(ClearActivityLogsStore));
48+
});
49+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { createDispatchMap, select } from '@ngxs/store';
2+
3+
import { TranslatePipe } from '@ngx-translate/core';
4+
5+
import { PaginatorState } from 'primeng/paginator';
6+
import { Skeleton } from 'primeng/skeleton';
7+
8+
import { DatePipe } from '@angular/common';
9+
import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, signal } from '@angular/core';
10+
import { ActivatedRoute } from '@angular/router';
11+
12+
import { CustomPaginatorComponent } from '@shared/components';
13+
import { ActivityLogDisplayService } from '@shared/services';
14+
import {
15+
ActivityLogsSelectors,
16+
ClearActivityLogsStore,
17+
GetRegistrationActivityLogs,
18+
} from '@shared/stores/activity-logs';
19+
20+
@Component({
21+
selector: 'osf-registration-recent-activity',
22+
imports: [TranslatePipe, Skeleton, DatePipe, CustomPaginatorComponent],
23+
templateUrl: './registration-recent-activity.component.html',
24+
styleUrl: './registration-recent-activity.component.scss',
25+
changeDetection: ChangeDetectionStrategy.OnPush,
26+
})
27+
export class RegistrationRecentActivityComponent implements OnDestroy {
28+
private readonly activityLogDisplayService = inject(ActivityLogDisplayService);
29+
private readonly route = inject(ActivatedRoute);
30+
31+
readonly pageSize = 10;
32+
33+
protected currentPage = signal<number>(1);
34+
protected activityLogs = select(ActivityLogsSelectors.getActivityLogs);
35+
protected totalCount = select(ActivityLogsSelectors.getActivityLogsTotalCount);
36+
protected isLoading = select(ActivityLogsSelectors.getActivityLogsLoading);
37+
protected firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize);
38+
39+
protected actions = createDispatchMap({
40+
getRegistrationActivityLogs: GetRegistrationActivityLogs,
41+
clearActivityLogsStore: ClearActivityLogsStore,
42+
});
43+
44+
protected formattedActivityLogs = computed(() => {
45+
const logs = this.activityLogs();
46+
return logs.map((log) => ({
47+
...log,
48+
formattedActivity: this.activityLogDisplayService.getActivityDisplay(log),
49+
}));
50+
});
51+
52+
constructor() {
53+
const registrationId = this.route.snapshot.params['id'] || this.route.parent?.snapshot.params['id'];
54+
if (registrationId) {
55+
this.actions.getRegistrationActivityLogs(registrationId, '1', String(this.pageSize));
56+
}
57+
}
58+
59+
onPageChange(event: PaginatorState) {
60+
if (event.page !== undefined) {
61+
const pageNumber = event.page + 1;
62+
this.currentPage.set(pageNumber);
63+
const registrationId = this.route.snapshot.params['id'] || this.route.parent?.snapshot.params['id'];
64+
if (registrationId) {
65+
this.actions.getRegistrationActivityLogs(registrationId, String(pageNumber), String(this.pageSize));
66+
}
67+
}
68+
}
69+
70+
ngOnDestroy(): void {
71+
this.actions.clearActivityLogsStore();
72+
}
73+
}

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { provideStates } from '@ngxs/store';
2-
32
import { Routes } from '@angular/router';
43

54
import { viewOnlyGuard } from '@osf/core/guards';
@@ -12,6 +11,7 @@ import {
1211
SubjectsState,
1312
ViewOnlyLinkState,
1413
} from '@osf/shared/stores';
14+
import { ActivityLogsState } from '@shared/stores/activity-logs';
1515

1616
import { AnalyticsState } from '../analytics/store';
1717
import { RegistriesState } from '../registries/store';
@@ -28,7 +28,7 @@ export const registryRoutes: Routes = [
2828
{
2929
path: '',
3030
component: RegistryComponent,
31-
providers: [provideStates([RegistryOverviewState])],
31+
providers: [provideStates([RegistryOverviewState, ActivityLogsState])],
3232
children: [
3333
{
3434
path: '',
@@ -113,6 +113,13 @@ export const registryRoutes: Routes = [
113113
loadComponent: () =>
114114
import('./pages/registry-wiki/registry-wiki.component').then((c) => c.RegistryWikiComponent),
115115
},
116+
{
117+
path: 'recent-activity',
118+
loadComponent: () =>
119+
import('./pages/recent-activity/registration-recent-activity.component').then(
120+
(c) => c.RegistrationRecentActivityComponent
121+
),
122+
},
116123
],
117124
},
118125
];

src/app/shared/services/activity-logs/activity-logs.service.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,21 @@ export class ActivityLogsService {
3333
.get<JsonApiResponseWithMeta<ActivityLogJsonApi[], MetaAnonymousJsonApi, null>>(url, params)
3434
.pipe(map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res)));
3535
}
36+
37+
fetchRegistrationLogs(
38+
registrationId: string,
39+
page = '1',
40+
pageSize: string
41+
): Observable<PaginatedData<ActivityLog[]>> {
42+
const url = `${environment.apiUrl}/registrations/${registrationId}/logs/`;
43+
const params: Record<string, unknown> = {
44+
'embed[]': ['original_node', 'user', 'linked_node', 'linked_registration', 'template_node', 'group'],
45+
page,
46+
'page[size]': pageSize,
47+
};
48+
49+
return this.jsonApiService
50+
.get<ResponseJsonApi<ActivityLogJsonApi[]>>(url, params)
51+
.pipe(map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res)));
52+
}
3653
}

src/app/shared/stores/activity-logs/activity-logs.actions.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ export class GetActivityLogs {
88
) {}
99
}
1010

11+
export class GetRegistrationActivityLogs {
12+
static readonly type = '[ActivityLogs] Get Registration Activity Logs';
13+
constructor(
14+
public registrationId: string,
15+
public page = '1',
16+
public pageSize: string
17+
) {}
18+
}
19+
1120
export class ClearActivityLogsStore {
1221
static readonly type = '[ActivityLogs] Clear Store';
1322
}

src/app/shared/stores/activity-logs/activity-logs.state.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { inject, Injectable } from '@angular/core';
66

77
import { ActivityLogsService } from '@shared/services';
88

9-
import { ClearActivityLogsStore, GetActivityLogs } from './activity-logs.actions';
9+
import { ClearActivityLogsStore, GetActivityLogs, GetRegistrationActivityLogs } from './activity-logs.actions';
1010
import { ACTIVITY_LOGS_STATE_DEFAULT, ActivityLogsStateModel } from './activity-logs.model';
1111

1212
@State<ActivityLogsStateModel>({
@@ -42,6 +42,21 @@ export class ActivityLogsState {
4242
);
4343
}
4444

45+
@Action(GetRegistrationActivityLogs)
46+
getRegistrationActivityLogs(ctx: StateContext<ActivityLogsStateModel>, action: GetRegistrationActivityLogs) {
47+
ctx.patchState({
48+
activityLogs: { data: [], isLoading: true, error: null, totalCount: 0 },
49+
});
50+
51+
return this.activityLogsService.fetchRegistrationLogs(action.registrationId, action.page, action.pageSize).pipe(
52+
tap((res) => {
53+
ctx.patchState({
54+
activityLogs: { data: res.data, isLoading: false, error: null, totalCount: res.totalCount },
55+
});
56+
})
57+
);
58+
}
59+
4560
@Action(ClearActivityLogsStore)
4661
clearActivityLogsStore(ctx: StateContext<ActivityLogsStateModel>) {
4762
ctx.setState(ACTIVITY_LOGS_STATE_DEFAULT);

0 commit comments

Comments
 (0)