Skip to content

Commit c65a7ff

Browse files
Port #1280 to dtq-dev-9-base: Refresh file listings on breadcrumb navigation after bitstream upload (#1498)
ADAPT completion of the #1280 port. The v9 squash carried 3 of the 9 files (clarin-files-section, preview-section runtime, and nothing else); the other six are byte-identical with vanilla dspace-9.3 on the base, i.e. the fork hunks were never applied. Without them the cache invalidation that did land is inert: the item resolver still asks for a cached item and never re-requests a stale one, so a file uploaded or deleted is not visible until a hard refresh. Only the missing hunks are applied here; clarin-files-section.component.ts and preview-section.component.ts are verified line by line as already complete and are not touched. - item.resolver.ts: findById(..., useCachedVersionIfAvailable=true, reRequestOnStale=false -> true). This is the load-bearing hunk - every setStaleByHrefSubstring below does nothing without it. - upload-bitstream.component.ts onCompleteItem: removeByHrefSubstring -> five setStaleByHrefSubstring calls (bundle bitstreams endpoint, item bundles endpoint, item self link, and metadatabitstreams/search/byHandle in both the encoded and the raw handle form). - item-bitstreams.component.ts submit(): the same invalidation after a delete. - bitstream-breadcrumbs.service.ts: hasValue(bitstream.bundle) / hasValue(bundle.item) guards, so a stale re-request that resolves an incomplete bitstream cannot throw in the breadcrumb resolver. - edit-bitstream-page.component.ts: the ngOnInit guards (bundle$, primaryBitstream$, item$, format$ each fall back to of(undefined); the combineLatest subscriber only assigns what it actually received) and setIiifStatus operating on its bitstream argument behind a null guard. - upload-bitstream.component.spec.ts: +5 tests, getBundlesEndpoint stub, mockItem._links.self, removeByHrefSubstring -> setStaleByHrefSubstring spy. - preview-section.component.spec.ts: restored (dropped whole by the squash), converted to a standalone TestBed. v9 notes: - rxjs `of` instead of the fork's `observableOf` alias (eslint dspace-angular-ts/alias-imports). - item.resolver.ts is a functional ResolveFn on v9; the flip is the same positional argument. - item-bitstreams.component.ts already imports hasValue, so the source's import hunk is a no-op here. - The vanilla-9 onSubmit() bundle$/combineLatest([bundle$, bitstream$]) save path is deliberately untouched - it is a different local bundle$. - preview-section.component.spec.ts: declarations -> imports:[PreviewSectionComponent], trailing commas and simple-import-sort order for the v9 eslint config. The three assertions are the fork's, unchanged. Card PB-01 (tranche T3). Source: 3a219e4 (dtq-dev PR #1280) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5f16046 commit c65a7ff

7 files changed

Lines changed: 232 additions & 27 deletions

File tree

src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -476,23 +476,38 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
476476
);
477477

478478
const bundle$ = bitstream$.pipe(
479-
switchMap((bitstream: Bitstream) => bitstream.bundle),
480-
getFirstSucceededRemoteDataPayload(),
479+
switchMap((bitstream: Bitstream) => {
480+
if (hasValue(bitstream) && hasValue(bitstream.bundle)) {
481+
return bitstream.bundle.pipe(getFirstSucceededRemoteDataPayload());
482+
}
483+
return of(undefined);
484+
}),
481485
);
482486

483487
const primaryBitstream$ = bundle$.pipe(
484-
hasValueOperator(),
485-
switchMap((bundle: Bundle) => this.bitstreamService.findByHref(bundle._links.primaryBitstream.href)),
486-
getFirstSucceededRemoteDataPayload(),
488+
switchMap((bundle: Bundle) => {
489+
if (hasValue(bundle) && hasValue(bundle._links) && hasValue(bundle._links.primaryBitstream) && hasValue(bundle._links.primaryBitstream.href)) {
490+
return this.bitstreamService.findByHref(bundle._links.primaryBitstream.href).pipe(getFirstSucceededRemoteDataPayload());
491+
}
492+
return of(undefined);
493+
}),
487494
);
488495

489496
const item$ = bundle$.pipe(
490-
switchMap((bundle: Bundle) => bundle.item),
491-
getFirstSucceededRemoteDataPayload(),
497+
switchMap((bundle: Bundle) => {
498+
if (hasValue(bundle) && hasValue(bundle.item)) {
499+
return bundle.item.pipe(getFirstSucceededRemoteDataPayload());
500+
}
501+
return of(undefined);
502+
}),
492503
);
493504
const format$ = bitstream$.pipe(
494-
switchMap(bitstream => bitstream.format),
495-
getFirstSucceededRemoteDataPayload(),
505+
switchMap((bitstream: Bitstream) => {
506+
if (hasValue(bitstream) && hasValue(bitstream.format)) {
507+
return bitstream.format.pipe(getFirstSucceededRemoteDataPayload());
508+
}
509+
return of(undefined);
510+
}),
496511
);
497512

498513
this.subs.push(
@@ -505,15 +520,23 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
505520
).subscribe(([bitstream, bundle, primaryBitstream, item, format]) => {
506521
this.bitstream = bitstream as Bitstream;
507522
this.bundle = bundle;
508-
this.selectedFormat = format;
523+
if (hasValue(format)) {
524+
this.selectedFormat = format;
525+
}
509526
// hasValue(primaryBitstream) because if there's no primaryBitstream on the bundle it will
510527
// be a success response, but empty
511528
this.primaryBitstreamUUID = hasValue(primaryBitstream) ? primaryBitstream.uuid : null;
512-
this.itemId = item.uuid;
529+
if (hasValue(item)) {
530+
this.itemId = item.uuid;
531+
}
513532
this.setIiifStatus(this.bitstream);
514533
}),
515534
format$.pipe(take(1)).subscribe(
516-
(format) => this.originalFormat = format,
535+
(format) => {
536+
if (hasValue(format)) {
537+
this.originalFormat = format;
538+
}
539+
},
517540
),
518541
);
519542

@@ -788,26 +811,38 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
788811
*/
789812
setIiifStatus(bitstream: Bitstream) {
790813

814+
if (!hasValue(bitstream) || !hasValue(bitstream.bundle) || !hasValue(bitstream.format)) {
815+
this.isIIIF = false;
816+
return;
817+
}
818+
791819
const regexExcludeBundles = /OTHERCONTENT|THUMBNAIL|LICENSE/;
792820
const regexIIIFItem = /true|yes/i;
793821

794-
const isImage$ = this.bitstream.format.pipe(
822+
const isImage$ = bitstream.format.pipe(
795823
getFirstSucceededRemoteData(),
796824
map((format: RemoteData<BitstreamFormat>) => format.payload.mimetype.includes('image/')));
797825

798-
const isIIIFBundle$ = this.bitstream.bundle.pipe(
826+
const isIIIFBundle$ = bitstream.bundle.pipe(
799827
getFirstSucceededRemoteData(),
800828
map((bundle: RemoteData<Bundle>) =>
801829
this.dsoNameService.getName(bundle.payload).match(regexExcludeBundles) == null));
802830

803-
const isEnabled$ = this.bitstream.bundle.pipe(
804-
getFirstSucceededRemoteData(),
805-
map((bundle: RemoteData<Bundle>) => bundle.payload.item.pipe(
806-
getFirstSucceededRemoteData(),
807-
map((item: RemoteData<Item>) =>
808-
(item.payload.firstMetadataValue('dspace.iiif.enabled') &&
809-
item.payload.firstMetadataValue('dspace.iiif.enabled').match(regexIIIFItem) !== null),
810-
))));
831+
const isEnabled$ = bitstream.bundle.pipe(
832+
getFirstSucceededRemoteDataPayload(),
833+
switchMap((bundle: Bundle) => {
834+
if (hasValue(bundle) && hasValue(bundle.item)) {
835+
return bundle.item.pipe(
836+
getFirstSucceededRemoteDataPayload(),
837+
map((item: Item) => {
838+
const iiifEnabledValue = item.firstMetadataValue('dspace.iiif.enabled');
839+
return hasValue(iiifEnabledValue) && iiifEnabledValue.match(regexIIIFItem) !== null;
840+
}),
841+
);
842+
}
843+
return of(false);
844+
}),
845+
);
811846

812847
const iiifSub = combineLatest(
813848
isImage$,

src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,12 @@ export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService {
7373
getFirstCompletedRemoteData(),
7474
getRemoteDataPayload(),
7575
switchMap((bitstream: Bitstream) => {
76-
if (hasValue(bitstream)) {
76+
if (hasValue(bitstream) && hasValue(bitstream.bundle)) {
7777
return bitstream.bundle.pipe(
7878
getFirstCompletedRemoteData(),
7979
getRemoteDataPayload(),
8080
switchMap((bundle: Bundle) => {
81-
if (hasValue(bundle)) {
81+
if (hasValue(bundle) && hasValue(bundle.item)) {
8282
return bundle.item.pipe(
8383
getFirstCompletedRemoteData(),
8484
);

src/app/item-page/bitstreams/upload/upload-bitstream.component.spec.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ describe('UploadBitstreamComponent', () => {
8080
const mockItem = Object.assign(new Item(), {
8181
id: 'fake-id',
8282
handle: 'fake/handle',
83+
_links: {
84+
self: {
85+
href: '/api/core/items/fake-id',
86+
},
87+
},
8388
metadata: {
8489
'dc.title': [
8590
{
@@ -96,6 +101,7 @@ describe('UploadBitstreamComponent', () => {
96101
const restEndpoint = 'fake-rest-endpoint';
97102
const mockItemDataService = jasmine.createSpyObj('mockItemDataService', {
98103
getBitstreamsEndpoint: of(restEndpoint),
104+
getBundlesEndpoint: of('/api/core/items/fake-id/bundles'),
99105
createBundle: createSuccessfulRemoteDataObject$(createdBundle),
100106
getBundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [bundle])),
101107
});
@@ -110,7 +116,7 @@ describe('UploadBitstreamComponent', () => {
110116
const notificationsServiceStub = new NotificationsServiceStub();
111117
const uploaderComponent = jasmine.createSpyObj('uploaderComponent', ['ngOnInit', 'ngAfterViewInit']);
112118
const requestService = jasmine.createSpyObj('requestService', {
113-
removeByHrefSubstring: {},
119+
setStaleByHrefSubstring: {},
114120
});
115121

116122
describe('when a file is uploaded', () => {
@@ -144,6 +150,28 @@ describe('UploadBitstreamComponent', () => {
144150
it('should navigate the user to the next page', () => {
145151
expect(routerStub.navigate).toHaveBeenCalled();
146152
});
153+
154+
it('should clear cached requests for the selected bundle bitstreams endpoint', () => {
155+
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(restEndpoint);
156+
});
157+
158+
it('should clear cached requests for the item bundles endpoint', () => {
159+
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('/api/core/items/fake-id/bundles');
160+
});
161+
162+
it('should clear cached requests for the item self endpoint', () => {
163+
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('/api/core/items/fake-id');
164+
});
165+
166+
it('should clear cached requests for the metadatabitstreams byHandle endpoint with encoded handle', () => {
167+
expect(requestService.setStaleByHrefSubstring)
168+
.toHaveBeenCalledWith('/api/core/metadatabitstreams/search/byHandle?handle=fake%2Fhandle&fileGrpType=ORIGINAL');
169+
});
170+
171+
it('should clear cached requests for the metadatabitstreams byHandle endpoint with raw handle', () => {
172+
expect(requestService.setStaleByHrefSubstring)
173+
.toHaveBeenCalledWith('/api/core/metadatabitstreams/search/byHandle?handle=fake/handle&fileGrpType=ORIGINAL');
174+
});
147175
});
148176
});
149177

src/app/item-page/bitstreams/upload/upload-bitstream.component.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,28 @@ export class UploadBitstreamComponent implements OnInit, OnDestroy {
248248
public onCompleteItem(bitstream) {
249249
// Clear cached requests for this bundle's bitstreams to ensure lists on all pages are up-to-date
250250
this.bundleService.getBitstreamsEndpoint(this.selectedBundleId).pipe(take(1)).subscribe((href: string) => {
251-
this.requestService.removeByHrefSubstring(href);
251+
this.requestService.setStaleByHrefSubstring(href);
252+
});
253+
254+
// Clear cached requests for this item's bundles to ensure bundle resolution uses fresh data
255+
this.itemService.getBundlesEndpoint(this.itemId).pipe(take(1)).subscribe((href: string) => {
256+
this.requestService.setStaleByHrefSubstring(href);
257+
});
258+
259+
// Clear cached requests for this item to ensure breadcrumb navigation resolves a fresh item
260+
this.itemRD$.pipe(
261+
getFirstSucceededRemoteDataPayload(),
262+
take(1),
263+
).subscribe((item: Item) => {
264+
this.requestService.setStaleByHrefSubstring(item._links.self.href);
265+
266+
// Clear metadatabitstreams search cache used by preview and CLARIN files sections
267+
if (item?.handle) {
268+
const byHandleBase = '/api/core/metadatabitstreams/search/byHandle';
269+
const encodedHandle = encodeURIComponent(item.handle);
270+
this.requestService.setStaleByHrefSubstring(`${byHandleBase}?handle=${encodedHandle}&fileGrpType=ORIGINAL`);
271+
this.requestService.setStaleByHrefSubstring(`${byHandleBase}?handle=${item.handle}&fileGrpType=ORIGINAL`);
272+
}
252273
});
253274

254275
// Bring over the item ID as a query parameter

src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,24 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme
274274
removedResponses$.subscribe((responses: RemoteData<NoContent>) => {
275275
this.itemBitstreamsService.displayNotifications('item.edit.bitstreams.notifications.remove', [responses]);
276276
this.submitting = false;
277+
278+
// Clear caches to ensure file lists are refreshed after bitstream removal (same as upload)
279+
this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => {
280+
if (!hasValue(bundles)) { return; }
281+
bundles.forEach((bundle: Bundle) => {
282+
if (bundle?._links?.bitstreams?.href) {
283+
this.requestService.setStaleByHrefSubstring(bundle._links.bitstreams.href);
284+
}
285+
});
286+
});
287+
288+
// Clear metadatabitstreams search cache used by preview and CLARIN files sections
289+
if (this.item?.handle) {
290+
const byHandleBase = '/api/core/metadatabitstreams/search/byHandle';
291+
const encodedHandle = encodeURIComponent(this.item.handle);
292+
this.requestService.setStaleByHrefSubstring(`${byHandleBase}?handle=${encodedHandle}&fileGrpType=ORIGINAL`);
293+
this.requestService.setStaleByHrefSubstring(`${byHandleBase}?handle=${this.item.handle}&fileGrpType=ORIGINAL`);
294+
}
277295
});
278296
}
279297

src/app/item-page/item.resolver.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const itemResolver: ResolveFn<RemoteData<Item>> = (
4848
const itemRD$ = itemService.findById(
4949
route.params.id,
5050
true,
51-
false,
51+
true,
5252
...getItemPageLinksToFollow(),
5353
).pipe(
5454
getFirstCompletedRemoteData(),
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { SimpleChange } from '@angular/core';
2+
import {
3+
ComponentFixture,
4+
TestBed,
5+
} from '@angular/core/testing';
6+
import {
7+
TranslateLoader,
8+
TranslateModule,
9+
} from '@ngx-translate/core';
10+
import {
11+
BehaviorSubject,
12+
of,
13+
} from 'rxjs';
14+
import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model';
15+
import { RegistryService } from 'src/app/core/registry/registry.service';
16+
import { HALLink } from 'src/app/core/shared/hal-link.model';
17+
import { Item } from 'src/app/core/shared/item.model';
18+
import { ResourceType } from 'src/app/core/shared/resource-type';
19+
20+
import { ConfigurationDataService } from '../../../../core/data/configuration-data.service';
21+
import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock';
22+
import { PreviewSectionComponent } from './preview-section.component';
23+
24+
describe('PreviewSectionComponent', () => {
25+
let component: PreviewSectionComponent;
26+
let fixture: ComponentFixture<PreviewSectionComponent>;
27+
let mockRegistryService: any;
28+
let mockConfigService: any;
29+
30+
beforeEach(async () => {
31+
mockConfigService = jasmine.createSpyObj(['findByPropertyName']);
32+
mockRegistryService = jasmine.createSpyObj('RegistryService', [
33+
'getMetadataBitstream',
34+
]);
35+
36+
await TestBed.configureTestingModule({
37+
imports: [
38+
TranslateModule.forRoot({
39+
loader: {
40+
provide: TranslateLoader,
41+
useClass: TranslateLoaderMock,
42+
},
43+
}),
44+
PreviewSectionComponent,
45+
],
46+
providers: [
47+
{ provide: RegistryService, useValue: mockRegistryService },
48+
{ provide: ConfigurationDataService, useValue: mockConfigService }],
49+
}).compileComponents();
50+
});
51+
52+
beforeEach(() => {
53+
fixture = TestBed.createComponent(PreviewSectionComponent);
54+
component = fixture.componentInstance;
55+
56+
// Set up the mock service's getMetadataBitstream method to return a simple stream
57+
const metadatabitstream = new MetadataBitstream();
58+
metadatabitstream.id = '5974f1cf-f2ef-4e4c-8f6d-85ad6c52efde';
59+
metadatabitstream.name = 'test';
60+
metadatabitstream.description = 'test';
61+
metadatabitstream.fileSize = 1024;
62+
metadatabitstream.checksum = 'abc';
63+
metadatabitstream.type = new ResourceType('item');
64+
metadatabitstream.fileInfo = [];
65+
metadatabitstream.format = 'text';
66+
metadatabitstream.canPreview = false;
67+
metadatabitstream._links = {
68+
self: new HALLink(),
69+
schema: new HALLink(),
70+
};
71+
72+
metadatabitstream._links.self.href = '';
73+
metadatabitstream._links.schema.href = '';
74+
const metadataBitstreams: MetadataBitstream[] = [metadatabitstream];
75+
const bitstreamStream = new BehaviorSubject(metadataBitstreams);
76+
mockRegistryService.getMetadataBitstream.and.returnValue(
77+
of(bitstreamStream),
78+
);
79+
80+
component.item = new Item();
81+
component.item.handle = '12345';
82+
83+
fixture.detectChanges();
84+
});
85+
86+
it('should create', () => {
87+
expect(component).toBeTruthy();
88+
});
89+
90+
it('should call getMetadataBitstream on item input change', () => {
91+
component.ngOnChanges({
92+
item: new SimpleChange(undefined, component.item, true),
93+
});
94+
expect(mockRegistryService.getMetadataBitstream).toHaveBeenCalled();
95+
});
96+
97+
it('should set listOfFiles on init', (done) => {
98+
component.listOfFiles.subscribe((files) => {
99+
expect(files).toEqual([]);
100+
done();
101+
});
102+
});
103+
});

0 commit comments

Comments
 (0)