Skip to content

Commit f2fe67b

Browse files
kosarkoCopilotamadulhaxxaniCopilot
authored
Refactor license contract page and improve collection display (ufal/clarin-dspace#114) (DSpace#1260)
* Display contract even if no collectionId given lists all authorized collections and their licenses * Proper cleanup replaced the nested manual subscriptions in license-contract-page.component.ts with a single RxJS pipeline using tap(...), switchMap(...), and takeUntil(this.destroy$), then completed cleanup in ngOnDestroy(). * fix non unique ids * Guard license stream with EMPTY and stricter check Guard license stream with EMPTY and stricter check * Refactor license contract page and improve collection display (#115) * Initial plan * Add error/loading handling for bogus collectionId in single-collection mode --------- --------- (cherry picked from commit 0ca1429) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: amadulhaxxani <hassan@ufal.mff.cuni.cz> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: kosarko <1842385+kosarko@users.noreply.github.com>
1 parent f8495ea commit f2fe67b

3 files changed

Lines changed: 235 additions & 48 deletions

File tree

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,48 @@
11
<div class="container">
2-
<div class="card" *ngVar="(collectionRD$ | async)?.payload as collection">
2+
<div class="card">
33
<h5 class="card-header">{{'contract.message.distribution-license-agreement' | translate}}</h5>
4-
<div class="card-body">
5-
<div class=" well" id="cz_cuni_mff_ufal_ContractPage_div_licenses">
6-
<h3>{{collection?.name}}</h3>
4+
<ng-container *ngIf="!isListMode(); else authorizedCollectionsTemplate">
5+
<ng-container *ngVar="(collectionRD$ | async) as collectionRD">
6+
<ds-error *ngIf="collectionRD?.hasFailed" message="{{'error.collection' | translate}}"></ds-error>
7+
<ds-themed-loading *ngIf="!collectionRD || collectionRD?.isLoading" message="{{'loading.collection' | translate}}"></ds-themed-loading>
8+
<div class="card-body" *ngIf="collectionRD?.payload">
9+
<div class=" well" id="cz_cuni_mff_ufal_ContractPage_div_licenses">
10+
<h3>{{collectionRD?.payload?.name}}</h3>
711
<textarea class="form-control" cols="0" id="cz_cuni_mff_ufal_ContractPage_field_license_text_18"
812
name="license_text_18" readonly
913
rows="34">{{(licenseRD$ | async)?.payload?.text}}</textarea>
10-
</div>
11-
</div>
14+
</div>
15+
</div>
16+
</ng-container>
17+
</ng-container>
1218
</div>
1319
</div>
20+
21+
<ng-template #authorizedCollectionsTemplate>
22+
<ng-container *ngVar="(collectionsRD$ | async) as collectionsRD">
23+
<ds-pagination *ngIf="collectionsRD?.payload?.totalElements > 0 || collectionsRD?.payload?.page?.length > 0"
24+
[paginationOptions]="pageConfig"
25+
[collectionSize]="collectionsRD?.payload?.totalElements"
26+
[hideGear]="true"
27+
[hidePagerWhenSinglePage]="true">
28+
<div class="card-body" *ngFor="let collection of collectionsRD?.payload?.page; let i = index">
29+
<div class=" well" id="cz_cuni_mff_ufal_ContractPage_div_licenses_{{i}}">
30+
<h3>{{collection?.name}}</h3>
31+
<textarea class="form-control"
32+
cols="0"
33+
readonly
34+
rows="34">{{(collection?.license | async)?.payload?.text}}</textarea>
35+
</div>
36+
</div>
37+
</ds-pagination>
38+
39+
<div *ngIf="collectionsRD?.payload?.totalElements === 0 || collectionsRD?.payload?.page?.length === 0"
40+
class="alert alert-info mt-3"
41+
role="alert">
42+
{{'collection.select.empty' | translate}}
43+
</div>
44+
45+
<ds-error *ngIf="collectionsRD?.hasFailed" message="{{'error.collections' | translate}}"></ds-error>
46+
<ds-themed-loading *ngIf="!collectionsRD || collectionsRD?.isLoading" message="{{'loading.collections' | translate}}"></ds-themed-loading>
47+
</ng-container>
48+
</ng-template>

src/app/license-contract-page/license-contract-page.component.spec.ts

Lines changed: 129 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,45 +5,107 @@ import { CommonModule } from '@angular/common';
55
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
66
import { TranslateModule } from '@ngx-translate/core';
77
import { ActivatedRoute, Params } from '@angular/router';
8-
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
8+
import { Directive, Input, NO_ERRORS_SCHEMA } from '@angular/core';
9+
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
910
import { CollectionDataService } from '../core/data/collection-data.service';
1011
import { Collection } from '../core/shared/collection.model';
11-
import { mockLicenseRD$ } from '../shared/testing/clarin-license-mock';
12-
import { take } from 'rxjs/operators';
13-
import { getFirstCompletedRemoteData } from '../core/shared/operators';
12+
import { License } from '../core/shared/license.model';
13+
import { PaginationService } from '../core/pagination/pagination.service';
14+
import { buildPaginatedList } from '../core/data/paginated-list.model';
15+
import { PageInfo } from '../core/shared/page-info.model';
16+
import { of as observableOf } from 'rxjs';
17+
import { FindListOptions } from '../core/data/find-list-options.model';
18+
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
19+
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
20+
21+
/* eslint-disable @angular-eslint/directive-selector */
22+
@Directive({
23+
selector: '[ngVar]'
24+
})
25+
class MockNgVarDirective {
26+
@Input() ngVar: unknown;
27+
}
1428

1529
describe('LicenseContractPageComponent', () => {
1630
let component: LicenseContractPageComponent;
1731
let fixture: ComponentFixture<LicenseContractPageComponent>;
1832

19-
let collection: Collection;
20-
2133
let routeStub: any;
22-
let collectionService: CollectionDataService;
34+
let collectionService: jasmine.SpyObj<CollectionDataService>;
35+
let paginationService: jasmine.SpyObj<PaginationService>;
2336

2437
const paramCollectionId = 'collectionId';
2538
const paramCollectionIdValue = '1';
2639

2740
const paramObject: Params = {};
2841
paramObject[paramCollectionId] = paramCollectionIdValue;
2942

30-
collection = Object.assign(new Collection(), {
43+
const singleCollectionLicense = Object.assign(new License(), {
44+
text: 'Single collection license text'
45+
});
46+
47+
const collection = Object.assign(new Collection(), {
3148
uuid: 'fake-collection-id',
49+
name: 'Single collection',
3250
_links: {
3351
self: {href: 'collection-selflink'},
3452
license: {href: 'license-link'}
3553
},
36-
license: mockLicenseRD$
54+
license: createSuccessfulRemoteDataObject$(singleCollectionLicense)
3755
});
3856

57+
const secondCollectionLicense = Object.assign(new License(), {
58+
text: 'Second collection license text'
59+
});
60+
61+
const authorizedCollections = [
62+
collection,
63+
Object.assign(new Collection(), {
64+
uuid: 'second-collection-id',
65+
name: 'Second collection',
66+
_links: {
67+
self: { href: 'second-collection-selflink' },
68+
license: { href: 'second-license-link' }
69+
},
70+
license: createSuccessfulRemoteDataObject$(secondCollectionLicense)
71+
})
72+
];
73+
74+
const authorizedCollectionsRD$ = createSuccessfulRemoteDataObject$(
75+
buildPaginatedList(
76+
new PageInfo({
77+
currentPage: 1,
78+
elementsPerPage: 10,
79+
totalElements: authorizedCollections.length,
80+
totalPages: 1
81+
}),
82+
authorizedCollections
83+
)
84+
);
85+
3986
routeStub = {
4087
snapshot: {
41-
queryParams: paramObject,
88+
queryParams: { ...paramObject },
4289
}
4390
};
4491

45-
collectionService = jasmine.createSpyObj('collectionService', {
46-
findById: createSuccessfulRemoteDataObject$(collection)
92+
collectionService = jasmine.createSpyObj<CollectionDataService>('collectionService', {
93+
findById: createSuccessfulRemoteDataObject$(collection),
94+
getAuthorizedCollection: authorizedCollectionsRD$
95+
});
96+
97+
paginationService = jasmine.createSpyObj<PaginationService>('paginationService', {
98+
getFindListOptions: observableOf(Object.assign(new FindListOptions(), {
99+
currentPage: 1,
100+
elementsPerPage: 10
101+
})),
102+
getCurrentPagination: observableOf(Object.assign(new PaginationComponentOptions(), {
103+
currentPage: 1,
104+
pageSize: 10,
105+
pageSizeOptions: [1, 5, 10, 20, 40, 60, 80, 100],
106+
})),
107+
getCurrentSort: observableOf(new SortOptions('name', SortDirection.ASC)),
108+
clearPagination: undefined
47109
});
48110

49111
beforeEach(async () => {
@@ -53,20 +115,29 @@ describe('LicenseContractPageComponent', () => {
53115
CommonModule,
54116
FormsModule,
55117
ReactiveFormsModule,
56-
TranslateModule.forRoot()
118+
TranslateModule.forRoot(),
57119
],
58120
declarations: [
59-
LicenseContractPageComponent
121+
LicenseContractPageComponent,
122+
MockNgVarDirective
60123
],
61124
providers: [
62125
{ provide: ActivatedRoute, useValue: routeStub },
63126
{ provide: CollectionDataService, useValue: collectionService },
64-
]
127+
{ provide: PaginationService, useValue: paginationService },
128+
],
129+
schemas: [NO_ERRORS_SCHEMA]
65130
})
66131
.compileComponents();
67132
});
68133

69134
beforeEach(() => {
135+
routeStub.snapshot.queryParams = { ...paramObject };
136+
collectionService.findById.and.returnValue(createSuccessfulRemoteDataObject$(collection));
137+
collectionService.findById.calls.reset();
138+
collectionService.getAuthorizedCollection.calls.reset();
139+
paginationService.getFindListOptions.calls.reset();
140+
paginationService.clearPagination.calls.reset();
70141
fixture = TestBed.createComponent(LicenseContractPageComponent);
71142
component = fixture.componentInstance;
72143
fixture.detectChanges();
@@ -77,19 +148,52 @@ describe('LicenseContractPageComponent', () => {
77148
});
78149

79150
it('should load collectionRD$', () => {
80-
collectionService.findById(collection.uuid)
81-
.pipe(getFirstCompletedRemoteData())
82-
.subscribe(collectionRD$ => {
83-
expect(component.collectionRD$.value).toEqual(collectionRD$);
84-
});
151+
expect(component.collectionRD$.value.payload).toEqual(collection);
85152
});
86153

87154
it('should load licenseRD$', () => {
88-
collection.license
89-
.pipe(take(1))
90-
.subscribe(licenseRD$ => {
91-
expect(component.licenseRD$.value).toEqual(licenseRD$);
92-
});
155+
expect(component.licenseRD$.value.payload).toEqual(singleCollectionLicense);
156+
});
157+
158+
it('should set hasFailed on collectionRD$ when collectionId is bogus', () => {
159+
collectionService.findById.and.returnValue(createFailedRemoteDataObject$('Not Found', 404));
160+
collectionService.findById.calls.reset();
161+
162+
const failFixture = TestBed.createComponent(LicenseContractPageComponent);
163+
const failComponent = failFixture.componentInstance;
164+
failFixture.detectChanges();
165+
166+
expect(collectionService.findById).toHaveBeenCalled();
167+
expect(failComponent.collectionRD$.value.hasFailed).toBeTrue();
168+
});
169+
170+
it('should load authorized collections when collectionId is missing', () => {
171+
routeStub.snapshot.queryParams = {};
172+
collectionService.findById.calls.reset();
173+
collectionService.getAuthorizedCollection.calls.reset();
174+
175+
const listFixture = TestBed.createComponent(LicenseContractPageComponent);
176+
const listComponent = listFixture.componentInstance;
177+
listFixture.detectChanges();
178+
179+
expect(collectionService.findById).not.toHaveBeenCalled();
180+
expect(collectionService.getAuthorizedCollection).toHaveBeenCalled();
181+
182+
listComponent.collectionsRD$.subscribe((collectionsRD) => {
183+
expect(collectionsRD.payload.page).toEqual(authorizedCollections);
184+
});
185+
});
186+
187+
it('should clear pagination state on destroy in list mode', () => {
188+
routeStub.snapshot.queryParams = {};
189+
paginationService.clearPagination.calls.reset();
190+
191+
const listFixture = TestBed.createComponent(LicenseContractPageComponent);
192+
const listComponent = listFixture.componentInstance;
193+
listFixture.detectChanges();
194+
listComponent.ngOnDestroy();
195+
196+
expect(paginationService.clearPagination).toHaveBeenCalledWith(listComponent.paginationId);
93197
});
94198

95199
});
Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
import { Component, OnInit } from '@angular/core';
1+
import { Component, OnDestroy, OnInit } from '@angular/core';
22
import { ActivatedRoute } from '@angular/router';
3-
import { BehaviorSubject } from 'rxjs';
3+
import { BehaviorSubject, EMPTY, Observable, Subject } from 'rxjs';
4+
import { filter, switchMap, takeUntil, tap } from 'rxjs/operators';
45
import { RemoteData } from '../core/data/remote-data';
56
import { Collection } from '../core/shared/collection.model';
67
import { CollectionDataService } from '../core/data/collection-data.service';
78
import { License } from '../core/shared/license.model';
89
import { followLink } from '../shared/utils/follow-link-config.model';
9-
import { filter } from 'rxjs/operators';
10-
import { isNotUndefined } from '../shared/empty.util';
10+
import { isNotEmpty } from '../shared/empty.util';
11+
import { PaginatedList } from '../core/data/paginated-list.model';
12+
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
13+
import { FindListOptions } from '../core/data/find-list-options.model';
14+
import { PaginationService } from '../core/pagination/pagination.service';
1115

1216
/**
1317
* The component load and show distribution license based on the collection.
@@ -17,10 +21,14 @@ import { isNotUndefined } from '../shared/empty.util';
1721
templateUrl: './license-contract-page.component.html',
1822
styleUrls: ['./license-contract-page.component.scss']
1923
})
20-
export class LicenseContractPageComponent implements OnInit {
24+
export class LicenseContractPageComponent implements OnInit, OnDestroy {
25+
26+
readonly paginationId = 'contract-collections';
27+
private readonly destroy$ = new Subject<void>();
2128

2229
constructor(private route: ActivatedRoute,
23-
protected collectionDataService: CollectionDataService,) {
30+
protected collectionDataService: CollectionDataService,
31+
protected paginationService: PaginationService,) {
2432
}
2533

2634
/**
@@ -38,18 +46,58 @@ export class LicenseContractPageComponent implements OnInit {
3846
*/
3947
licenseRD$: BehaviorSubject<RemoteData<License>> = new BehaviorSubject<RemoteData<License>>(null);
4048

49+
/**
50+
* Collection list RemoteData object loaded from the API.
51+
*/
52+
collectionsRD$: Observable<RemoteData<PaginatedList<Collection>>>;
53+
54+
/**
55+
* The current pagination configuration for the page used by the authorized collection request.
56+
*/
57+
config: FindListOptions = Object.assign(new FindListOptions(), {
58+
elementsPerPage: 10
59+
});
60+
61+
/**
62+
* The current pagination configuration for the page.
63+
*/
64+
pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
65+
id: this.paginationId,
66+
pageSize: 10
67+
});
68+
4169
ngOnInit(): void {
4270
this.collectionId = this.route.snapshot.queryParams.collectionId;
43-
this.collectionDataService.findById(this.collectionId, false, true, followLink('license'))
44-
.pipe(
45-
filter((collectionData: RemoteData<Collection>) => isNotUndefined((collectionData.payload))))
46-
.subscribe(res => {
47-
// load collection
48-
this.collectionRD$.next(res);
49-
res.payload.license.subscribe(licenseRD$ => {
50-
// load license of the collection
51-
this.licenseRD$.next(licenseRD$);
52-
});
53-
});
71+
if (isNotEmpty(this.collectionId)) {
72+
this.collectionDataService.findById(this.collectionId, false, true, followLink('license'))
73+
.pipe(
74+
tap((collectionData: RemoteData<Collection>) => this.collectionRD$.next(collectionData)),
75+
filter((collectionData: RemoteData<Collection>) => isNotEmpty(collectionData.payload)),
76+
switchMap((collectionData: RemoteData<Collection>) => collectionData.payload.license ?? EMPTY),
77+
tap((licenseRD: RemoteData<License>) => this.licenseRD$.next(licenseRD)),
78+
takeUntil(this.destroy$)
79+
)
80+
.subscribe();
81+
} else {
82+
this.loadAuthorizedCollections();
83+
}
84+
}
85+
86+
ngOnDestroy(): void {
87+
this.destroy$.next();
88+
this.destroy$.complete();
89+
if (this.isListMode()) {
90+
this.paginationService.clearPagination(this.paginationId);
91+
}
92+
}
93+
94+
isListMode(): boolean {
95+
return !isNotEmpty(this.collectionId);
96+
}
97+
98+
private loadAuthorizedCollections(): void {
99+
this.collectionsRD$ = this.paginationService.getFindListOptions(this.paginationId, this.config).pipe(
100+
switchMap((config: FindListOptions) => this.collectionDataService.getAuthorizedCollection('', config, true, true, followLink('license')))
101+
);
54102
}
55103
}

0 commit comments

Comments
 (0)