Skip to content

Commit 2c1291d

Browse files
author
Andrea Barbasso
committed
Merged dspace-cris-2024_02_x into task/dspace-cris-2024_02_x/DSC-2953
2 parents aae19a1 + f82016a commit 2c1291d

102 files changed

Lines changed: 1021 additions & 307 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bitbucket-pipelines.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
options:
2-
runs-on: self.hosted
3-
41
definitions:
52
caches:
63
cypress-dsc-2024-02-x: ~/.cache/Cypress

src/app/admin/admin-import-batch-page/batch-import-page.component.html

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ <h1 id="header">{{'admin.batch-import.page.header' | translate}}</h1>
2121
</div>
2222

2323
<ds-switch
24-
[options]="switchOptions"
24+
[onOption]="switchOnOption"
25+
[offOption]="switchOffOption"
2526
[selectedValue]="isUpload ? 'upload' : 'url'"
2627
(selectedValueChange)="toggleUpload()">
2728
</ds-switch>
@@ -47,8 +48,13 @@ <h1 id="header">{{'admin.batch-import.page.header' | translate}}</h1>
4748
<button class="btn btn-secondary" id="backButton"
4849
(click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button>
4950
<button class="btn btn-primary" id="proceedButton"
51+
[dsBtnDisabled]="isProcessing"
5052
(click)="this.importMetadata();">
51-
<i class="fas fa-arrow-right mr-1"></i>
53+
@if (isProcessing) {
54+
<i class="fas fa-spinner fa-spin me-1"></i>
55+
} @else {
56+
<i class="fas fa-arrow-right me-1"></i>
57+
}
5258
{{'admin.metadata-import.page.button.proceed' | translate}}</button>
5359
</div>
5460
</div>

src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,40 @@ describe('BatchImportPageComponent', () => {
8787
expect(component).toBeTruthy();
8888
});
8989

90+
describe('processing state', () => {
91+
beforeEach(() => {
92+
component.fileURL = 'http://example.com/file.zip';
93+
});
94+
95+
it('should disable proceed button when processing', () => {
96+
component.isProcessing = true;
97+
fixture.detectChanges();
98+
const button = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
99+
expect(button.getAttribute('aria-disabled')).toBe('true');
100+
});
101+
102+
it('should show spinner icon when processing', () => {
103+
component.isProcessing = true;
104+
fixture.detectChanges();
105+
const spinner = fixture.debugElement.query(By.css('#proceedButton .fa-spinner'));
106+
expect(spinner).toBeTruthy();
107+
});
108+
109+
it('should show arrow icon when not processing', () => {
110+
component.isProcessing = false;
111+
fixture.detectChanges();
112+
const arrow = fixture.debugElement.query(By.css('#proceedButton .fa-arrow-right'));
113+
expect(arrow).toBeTruthy();
114+
});
115+
116+
it('should not invoke script if already processing', () => {
117+
component.isProcessing = true;
118+
const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
119+
proceed.click();
120+
expect(scriptService.invoke).not.toHaveBeenCalled();
121+
});
122+
});
123+
90124
describe('if back button is pressed', () => {
91125
beforeEach(fakeAsync(() => {
92126
const proceed = fixture.debugElement.query(By.css('#backButton')).nativeElement;

src/app/admin/admin-import-batch-page/batch-import-page.component.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010
TranslateModule,
1111
TranslateService,
1212
} from '@ngx-translate/core';
13-
import { take } from 'rxjs/operators';
13+
import {
14+
finalize,
15+
take,
16+
} from 'rxjs/operators';
1417

1518
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
1619
import {
@@ -23,6 +26,7 @@ import { getFirstCompletedRemoteData } from '../../core/shared/operators';
2326
import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths';
2427
import { Process } from '../../process-page/processes/process.model';
2528
import { ProcessParameter } from '../../process-page/processes/process-parameter.model';
29+
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
2630
import { ImportBatchSelectorComponent } from '../../shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component';
2731
import {
2832
isEmpty,
@@ -43,6 +47,7 @@ import { FileDropzoneNoUploaderComponent } from '../../shared/upload/file-dropzo
4347
NgIf,
4448
TranslateModule,
4549
FormsModule,
50+
BtnDisabledDirective,
4651
FileDropzoneNoUploaderComponent,
4752
SwitchComponent,
4853
],
@@ -59,6 +64,11 @@ export class BatchImportPageComponent {
5964
*/
6065
validateOnly = true;
6166

67+
/**
68+
* Whether a batch import is currently being processed
69+
*/
70+
isProcessing = false;
71+
6272
/**
6373
* dso object for community or collection
6474
*/
@@ -75,12 +85,14 @@ export class BatchImportPageComponent {
7585
fileURL: string;
7686

7787
/**
78-
* The custom options for the 'ds-switch' component
88+
* The "on" option for the 'ds-switch' component (upload)
7989
*/
80-
switchOptions: SwitchOption[] = [
81-
{ value: 'upload', icon: 'fa fa-upload', label: 'admin.metadata-import.page.toggle.upload', iconColor: SwitchColor.Primary },
82-
{ value: 'url', icon: 'fa fa-link', label: 'admin.metadata-import.page.toggle.url', iconColor: SwitchColor.Primary },
83-
];
90+
switchOnOption: SwitchOption = { value: 'upload', icon: 'fa fa-upload', label: 'admin.metadata-import.page.toggle.upload', iconColor: SwitchColor.Primary };
91+
92+
/**
93+
* The "off" option for the 'ds-switch' component (url)
94+
*/
95+
switchOffOption: SwitchOption = { value: 'url', icon: 'fa fa-link', label: 'admin.metadata-import.page.toggle.url', iconColor: SwitchColor.Primary };
8496

8597
public constructor(private location: Location,
8698
protected translate: TranslateService,
@@ -117,6 +129,9 @@ export class BatchImportPageComponent {
117129
* Starts import-metadata script with --zip fileName (and the selected file)
118130
*/
119131
public importMetadata() {
132+
if (this.isProcessing) {
133+
return;
134+
}
120135
if (this.fileObject == null && isEmpty(this.fileURL)) {
121136
if (this.isUpload) {
122137
this.notificationsService.error(this.translate.get('admin.metadata-import.page.error.addFile'));
@@ -140,8 +155,10 @@ export class BatchImportPageComponent {
140155
parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true }));
141156
}
142157

158+
this.isProcessing = true;
143159
this.scriptDataService.invoke(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe(
144160
getFirstCompletedRemoteData(),
161+
finalize(() => this.isProcessing = false),
145162
).subscribe((rd: RemoteData<Process>) => {
146163
if (rd.hasSucceeded) {
147164
const title = this.translate.get('process.new.notification.success.title');
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { getTestScheduler } from 'jasmine-marbles';
2+
3+
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
4+
import { Item } from '../shared/item.model';
5+
import { editItemBreadcrumbResolver } from './edit-item-breadcrumb.resolver';
6+
7+
describe('editItemBreadcrumbResolver', () => {
8+
describe('resolve', () => {
9+
let resolver: any;
10+
let dsoBreadcrumbService: any;
11+
let itemService: any;
12+
let testItem: Item;
13+
let uuid;
14+
let breadcrumbUrl;
15+
let currentUrl;
16+
17+
beforeEach(() => {
18+
uuid = '1234-65487-12354-1235';
19+
breadcrumbUrl = `/items/${uuid}`;
20+
currentUrl = `/edititems/${uuid}`;
21+
testItem = Object.assign(new Item(), {
22+
uuid: uuid,
23+
type: 'item',
24+
});
25+
dsoBreadcrumbService = {};
26+
itemService = {
27+
findById: jasmine.createSpy('findById').and.returnValue(createSuccessfulRemoteDataObject$(testItem)),
28+
};
29+
resolver = editItemBreadcrumbResolver;
30+
});
31+
32+
it('should resolve a breadcrumb config for the item when the route id is a plain uuid', () => {
33+
const resolvedConfig = resolver({ params: { id: uuid } } as any, { url: currentUrl } as any, dsoBreadcrumbService, itemService);
34+
const expectedConfig = { provider: dsoBreadcrumbService, key: testItem, url: breadcrumbUrl };
35+
getTestScheduler().expectObservable(resolvedConfig).toBe('(a|)', { a: expectedConfig });
36+
getTestScheduler().flush();
37+
expect(itemService.findById.calls.mostRecent().args.slice(0, 2)).toEqual([uuid, true]);
38+
});
39+
40+
it('should strip the workspace/workflow item id suffix before resolving, when the route id is composite', () => {
41+
const compositeId = `${uuid}:12345`;
42+
const resolvedConfig = resolver({ params: { id: compositeId } } as any, { url: currentUrl } as any, dsoBreadcrumbService, itemService);
43+
const expectedConfig = { provider: dsoBreadcrumbService, key: testItem, url: breadcrumbUrl };
44+
getTestScheduler().expectObservable(resolvedConfig).toBe('(a|)', { a: expectedConfig });
45+
getTestScheduler().flush();
46+
expect(itemService.findById.calls.mostRecent().args.slice(0, 2)).toEqual([uuid, true]);
47+
});
48+
});
49+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { inject } from '@angular/core';
2+
import {
3+
ActivatedRouteSnapshot,
4+
ResolveFn,
5+
RouterStateSnapshot,
6+
} from '@angular/router';
7+
import { Observable } from 'rxjs';
8+
9+
import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model';
10+
import { getItemPageLinksToFollow } from '../../item-page/item.resolver';
11+
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
12+
import { ItemDataService } from '../data/item-data.service';
13+
import { DSpaceObject } from '../shared/dspace-object.model';
14+
import { Item } from '../shared/item.model';
15+
import { DSOBreadcrumbResolverByUuid } from './dso-breadcrumb.resolver';
16+
import { DSOBreadcrumbsService } from './dso-breadcrumbs.service';
17+
18+
/**
19+
* The resolve function that resolves the BreadcrumbConfig object for an Item from "Edit all details" page
20+
*/
21+
export const editItemBreadcrumbResolver: ResolveFn<BreadcrumbConfig<Item>> = (
22+
route: ActivatedRouteSnapshot,
23+
state: RouterStateSnapshot,
24+
breadcrumbService: DSOBreadcrumbsService = inject(DSOBreadcrumbsService),
25+
dataService: ItemDataService = inject(ItemDataService),
26+
): Observable<BreadcrumbConfig<Item>> => {
27+
const linksToFollow: FollowLinkConfig<DSpaceObject>[] = getItemPageLinksToFollow() as FollowLinkConfig<DSpaceObject>[];
28+
const itemIdString = route.params.id;
29+
const itemId = itemIdString?.split(':')[0];
30+
return DSOBreadcrumbResolverByUuid(
31+
route,
32+
state,
33+
itemId,
34+
breadcrumbService,
35+
dataService,
36+
...linksToFollow,
37+
) as Observable<BreadcrumbConfig<Item>>;
38+
};

src/app/core/data/feature-authorization/authorization-data.service.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,5 +333,34 @@ describe('AuthorizationDataService', () => {
333333
});
334334
});
335335
});
336+
337+
describe('when the store-based authorization is not populated after loading (SSR fallback)', () => {
338+
beforeEach(() => {
339+
// The request finished loading but the NgRx authorization store was never populated for
340+
// this feature. This happens during SSR: the store-based flow yields `undefined` even
341+
// though the REST request succeeded. The service must then fall back to a direct REST
342+
// authorization check instead of hanging or wrongly returning false.
343+
authorizationService.getAuthorizationForObject = () => observableOf(undefined);
344+
authorizationService.isRequestLoading = () => observableOf(false);
345+
});
346+
347+
it('should fall back to a direct REST check and return true when the feature is granted', (done) => {
348+
spyOn(service, 'searchByObject').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(validPayload)));
349+
service.isAuthorized(featureID).subscribe((result) => {
350+
expect(service.searchByObject).toHaveBeenCalled();
351+
expect(result).toEqual(true);
352+
done();
353+
});
354+
});
355+
356+
it('should fall back to a direct REST check and return false when the feature is not granted', (done) => {
357+
spyOn(service, 'searchByObject').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(emptyPayload)));
358+
service.isAuthorized(featureID).subscribe((result) => {
359+
expect(service.searchByObject).toHaveBeenCalled();
360+
expect(result).toEqual(false);
361+
done();
362+
});
363+
});
364+
});
336365
});
337366
});

src/app/core/data/feature-authorization/authorization-data.service.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -120,28 +120,37 @@ export class AuthorizationDataService extends BaseDataService<Authorization> imp
120120
return dsoRequest$.pipe(
121121
take(1),
122122
// Get correct item and check that has not already pending authorizations
123-
switchMap((object) => this.readOrFetchAuthorization(object, featureId, !objectUrl)),
123+
switchMap((object) => this.readOrFetchAuthorization(object, featureId, !objectUrl, useCachedVersionIfAvailable, reRequestOnStale)),
124124
);
125125
} else {
126126
// we fallback on old method if site service had initialization issues or if some parameters more than the only feature ID are provided.
127-
return this.searchByObject(featureId, objectUrl, ePersonUuid, {}, useCachedVersionIfAvailable, reRequestOnStale, followLink('feature')).pipe(
128-
getFirstCompletedRemoteData(),
129-
map((authorizationRD) => {
130-
if (authorizationRD.statusCode !== 401 && hasValue(authorizationRD.payload) && isNotEmpty(authorizationRD.payload.page)) {
131-
return authorizationRD.payload.page;
132-
} else {
133-
return [];
134-
}
135-
}),
136-
catchError(() => observableOf([])),
137-
oneAuthorizationMatchesFeature(featureId),
138-
);
127+
return this.searchByObjectAndMatchFeature(featureId, objectUrl, ePersonUuid, useCachedVersionIfAvailable, reRequestOnStale);
139128
}
140129
}),
141130
);
142131
}
143132

144-
private readOrFetchAuthorization(dso: DSpaceObject, featureId: FeatureID, isSite = false): Observable<boolean> {
133+
/**
134+
* Perform a direct authorization check via the REST "object" search endpoint, bypassing the
135+
* NgRx authorization store. This reads the {@link RemoteData} directly, so it returns a reliable
136+
* result even in situations where the store-based flow does not get populated (e.g. during SSR).
137+
*/
138+
private searchByObjectAndMatchFeature(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable<boolean> {
139+
return this.searchByObject(featureId, objectUrl, ePersonUuid, {}, useCachedVersionIfAvailable, reRequestOnStale, followLink('feature')).pipe(
140+
getFirstCompletedRemoteData(),
141+
map((authorizationRD) => {
142+
if (authorizationRD.statusCode !== 401 && hasValue(authorizationRD.payload) && isNotEmpty(authorizationRD.payload.page)) {
143+
return authorizationRD.payload.page;
144+
} else {
145+
return [];
146+
}
147+
}),
148+
catchError(() => observableOf([])),
149+
oneAuthorizationMatchesFeature(featureId),
150+
);
151+
}
152+
153+
private readOrFetchAuthorization(dso: DSpaceObject, featureId: FeatureID, isSite = false, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable<boolean> {
145154
const requestId = getRequestIdFromParams(dso.uniqueType, [getNormalizedUuid(dso)], [featureId]);
146155
// if is the site init we wait for the authorization to be loaded otherwise services that run on resolver won't find a state.
147156
const waitForEntry$: Observable<boolean> = isSite
@@ -174,8 +183,18 @@ export class AuthorizationDataService extends BaseDataService<Authorization> imp
174183
take(1), // Ensure we only continue after loading finishes
175184
switchMap(() =>
176185
this.authorizationService.getAuthorizationForObject(featureId, dso.self).pipe(
177-
filter(result => result !== undefined), // Ensure we only emit valid results
178186
take(1),
187+
switchMap((result) => {
188+
if (result !== undefined) {
189+
return observableOf(result);
190+
}
191+
// The store-based flow did not yield a value even though the request finished.
192+
// This happens during SSR, where the NgRx authorization state is not populated
193+
// reliably (the guard then wrongly saw `false` and redirected to /403, while CSR
194+
// worked). Fall back to a direct REST authorization check, which reads the
195+
// RemoteData directly and returns a correct result.
196+
return this.searchByObjectAndMatchFeature(featureId, dso.self, undefined, useCachedVersionIfAvailable, reRequestOnStale);
197+
}),
179198
),
180199
),
181200
);

src/app/core/notifications/suggestions/target/suggestion-target-data.service.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ describe('SuggestionTargetDataService test', () => {
120120
scheduler.schedule(() => service.getTargetsByUser('testId', options).subscribe());
121121
scheduler.flush();
122122

123-
expect(requestService.send).toHaveBeenCalledWith(expected, true);
123+
expect(requestService.send).toHaveBeenCalledWith(expected, false);
124124
});
125125
});
126126

src/app/core/notifications/suggestions/target/suggestion-target-data.service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export class SuggestionTargetDataService extends IdentifiableDataService<Suggest
8484
* The user Id for which to find targets.
8585
* @param options
8686
* Find list options object.
87+
* @param useCachedVersionIfAvailable
88+
* If this is true, the request will only be sent if there's no valid cached version. Defaults to true
8789
* @param linksToFollow
8890
* List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved.
8991
* @return Observable<RemoteData<PaginatedList<SuggestionTarget>>>
@@ -92,12 +94,13 @@ export class SuggestionTargetDataService extends IdentifiableDataService<Suggest
9294
public getTargetsByUser(
9395
userId: string,
9496
options: FindListOptions = {},
97+
useCachedVersionIfAvailable = false,
9598
...linksToFollow: FollowLinkConfig<SuggestionTarget>[]
9699
): Observable<RemoteData<PaginatedList<SuggestionTarget>>> {
97100
options.searchParams = [new RequestParam('target', userId)];
98-
99-
return this.searchBy(this.searchFindByTargetMethod, options, true, true, ...linksToFollow);
101+
return this.searchBy(this.searchFindByTargetMethod, options, useCachedVersionIfAvailable, true, ...linksToFollow);
100102
}
103+
101104
/**
102105
* Return a Suggestion Target for a given id
103106
*

0 commit comments

Comments
 (0)