Skip to content

Commit c21d270

Browse files
milanmajchrakclaude
andcommitted
Port #1269 to dtq-dev-9-base: UFAL/Add 'Add URL bitstream' feature to workspace items
An editable workspace item in /mydspace gains an "Add bitstream from URL" action (link icon + tooltip), shown when the user can edit the item AND the `file-downloader` script exists and is executable for them. It opens a modal with a required URL and an optional bitstream name, invokes the script with `-u <url> -w <workspaceitem id>` (plus `-n <name>` only when a name was given), notifies, and navigates to `/processes/<id>`. The backend half is already on 9-base: `dspace/config/spring/rest/scripts.xml` declares the `file-downloader` bean and `FileDownloader.java` is byte-identical with dtq-dev, so this is a front-end-only port. v9 adaptations against the 9-base pre-image (which is NOT vanilla - it already carries the CLARIN share-submission action, so this is a partial-port file, not a vanilla-wholesale one): * standalone component: `FormsModule` (for `[(ngModel)]`) and `BtnDisabledDirective` added to `imports[]`. * `[disabled]` -> `[dsBtnDisabled]` on the modal's Add button - 9-base's `dspace-angular-html/no-disabled-attribute-on-button` is an eslint *error*. * modal close button `class="close"` + `&times;` -> Bootstrap 5 `btn-close`, matching the sibling discard modal in the same template. * `form-group` -> `mb-3` (Bootstrap 5). * every `*ngIf` -> `@if`, matching the rest of the file after the v9 migration. * `getProcessDetailRoute(String(rd.payload.processId))` - `Process.processId` is declared `string` but the REST payload delivers a JSON number (the spec's own fixtures use `{ processId: 202 }`), so `String()` keeps the route stable. * `void this.router.navigateByUrl(...)` - the neighbouring `shareSubmission()` on 9-base already writes `void this.router.navigate(...)`; without it the port would add a new `no-floating-promises` warning. * spec: the existing 9-base standalone TestBed plus a `ScriptDataService` spy; `of` instead of `of as observableOf`. 8 -> 13 `it()`. * en.json5: the 9 keys are added in the source's relative position (after `submission.workflow.generic.share-submission.tooltip`) but without blank lines between them, matching the compact style of the CLARIN block they land in; cs.json5 keeps the `// "<key>": "<EN>"` comment convention (+27 lines, exactly as in the source). Source: 1c285b4 (dtq-dev PR #1269) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bba4d90 commit c21d270

5 files changed

Lines changed: 219 additions & 0 deletions

File tree

src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.html

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@
4848
</span>
4949
</button>
5050
}
51+
52+
@if ((canEditItem$ | async) && (canUseFileDownloader$ | async)) {
53+
<button type="button" id="{{'add_url_bitstream_' + object.id}}" class="btn btn-outline-primary mt-1 mb-3"
54+
[ngbTooltip]="'submission.workflow.generic.add-url-bitstream.tooltip' | translate"
55+
[attr.aria-label]="'submission.workflow.generic.add-url-bitstream.tooltip' | translate"
56+
(click)="$event.preventDefault();openAddBitstreamFromUrlModal(addBitstreamFromUrlModal)">
57+
<i class="fa fa-link" aria-hidden="true"></i> {{'submission.workflow.generic.add-url-bitstream' | translate}}
58+
</button>
59+
}
5160
</div>
5261

5362

@@ -67,3 +76,42 @@
6776
(click)="c('ok')">{{'submission.general.discard.confirm.submit' | translate}}</button>
6877
</div>
6978
</ng-template>
79+
80+
<ng-template #addBitstreamFromUrlModal let-c="close" let-d="dismiss">
81+
<div class="modal-header">
82+
<div class="modal-title h4">{{'submission.workflow.generic.add-url-bitstream.modal.title' | translate}}</div>
83+
<button type="button" class="btn-close" aria-label="Close" (click)="d('cancel')">
84+
</button>
85+
</div>
86+
<div class="modal-body">
87+
<div class="mb-3">
88+
<label for="bitstream-url-input">{{'submission.workflow.generic.add-url-bitstream.url.label' | translate}}</label>
89+
<input id="bitstream-url-input" name="bitstream-url-input" class="form-control" type="url"
90+
[(ngModel)]="bitstreamFromUrl" required>
91+
@if (!bitstreamFromUrl?.trim()) {
92+
<small class="text-danger">
93+
{{'submission.workflow.generic.add-url-bitstream.url.required' | translate}}
94+
</small>
95+
}
96+
</div>
97+
<div class="mb-3">
98+
<label for="bitstream-name-input">{{'submission.workflow.generic.add-url-bitstream.name.label' | translate}}</label>
99+
<input id="bitstream-name-input" name="bitstream-name-input" class="form-control" type="text"
100+
[placeholder]="'submission.workflow.generic.add-url-bitstream.name.placeholder' | translate"
101+
[(ngModel)]="bitstreamName">
102+
</div>
103+
</div>
104+
<div class="modal-footer">
105+
<button type="button" class="btn btn-secondary" (click)="c('cancel')">
106+
{{'submission.workflow.generic.add-url-bitstream.cancel' | translate}}
107+
</button>
108+
<button type="button" class="btn btn-primary"
109+
[dsBtnDisabled]="!bitstreamFromUrl?.trim() || (processingAddFromUrl$ | async)"
110+
(click)="addBitstreamFromUrl(c)">
111+
@if ((processingAddFromUrl$ | async)) {
112+
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
113+
}
114+
{{'submission.workflow.generic.add-url-bitstream.submit' | translate}}
115+
</button>
116+
</div>
117+
</ng-template>

src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ import { of } from 'rxjs';
2626
import { AuthService } from '../../../core/auth/auth.service';
2727
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
2828
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
29+
import { ScriptDataService } from '../../../core/data/processes/script-data.service';
2930
import { RequestService } from '../../../core/data/request.service';
3031
import { EPerson } from '../../../core/eperson/models/eperson.model';
3132
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
3233
import { Item } from '../../../core/shared/item.model';
3334
import { SearchService } from '../../../core/shared/search/search.service';
3435
import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model';
3536
import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service';
37+
import { getProcessDetailRoute } from '../../../process-page/process-page-routing.paths';
3638
import { getMockRemoteDataBuildService } from '../../mocks/remote-data-build.service.mock';
3739
import { getMockRequestService } from '../../mocks/request.service.mock';
3840
import { getMockSearchService } from '../../mocks/search-service.mock';
@@ -56,6 +58,7 @@ let mockObject: WorkspaceItem;
5658
let notificationsServiceStub: NotificationsServiceStub;
5759
let authorizationService;
5860
let authService;
61+
let scriptDataService;
5962

6063
const mockDataService = jasmine.createSpyObj('WorkspaceitemDataService', {
6164
delete: jasmine.createSpy('delete'),
@@ -176,6 +179,11 @@ authService = jasmine.createSpyObj('authService', {
176179
getAuthenticatedUserFromStore: jasmine.createSpy('getAuthenticatedUserFromStore'),
177180
});
178181

182+
scriptDataService = jasmine.createSpyObj('scriptDataService', {
183+
scriptWithNameExistsAndCanExecute: jasmine.createSpy('scriptWithNameExistsAndCanExecute'),
184+
invoke: jasmine.createSpy('invoke'),
185+
});
186+
179187
describe('WorkspaceitemActionsComponent', () => {
180188
beforeEach(waitForAsync(async () => {
181189
authorizationService = jasmine.createSpyObj('authorizationService', {
@@ -204,6 +212,7 @@ describe('WorkspaceitemActionsComponent', () => {
204212
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
205213
{ provide: HALEndpointService, useValue: new HALEndpointServiceStub('https://rest.api/server/api') },
206214
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService() },
215+
{ provide: ScriptDataService, useValue: scriptDataService },
207216
NgbModal,
208217
],
209218
schemas: [NO_ERRORS_SCHEMA],
@@ -218,6 +227,7 @@ describe('WorkspaceitemActionsComponent', () => {
218227
component.object = mockObject;
219228
notificationsServiceStub = TestBed.inject(NotificationsService as any);
220229
(authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(of(ePersonMock));
230+
(scriptDataService.scriptWithNameExistsAndCanExecute as jasmine.Spy).and.returnValue(of(true));
221231
fixture.detectChanges();
222232
});
223233

@@ -251,6 +261,61 @@ describe('WorkspaceitemActionsComponent', () => {
251261
expect(btn).not.toBeNull();
252262
});
253263

264+
it('should display add URL bitstream button when script is executable', () => {
265+
const btn = fixture.debugElement.query(By.css('#add_url_bitstream_1234'));
266+
267+
expect(btn).not.toBeNull();
268+
});
269+
270+
it('should not display add URL bitstream button when script is not executable', () => {
271+
component.canUseFileDownloader$ = of(false);
272+
fixture.detectChanges();
273+
274+
const btn = fixture.debugElement.query(By.css('#add_url_bitstream_1234'));
275+
expect(btn).toBeNull();
276+
});
277+
278+
it('should invoke file-downloader with -u and -w and optional -n', () => {
279+
const closeModal = jasmine.createSpy('closeModal');
280+
const process = { processId: 101 } as any;
281+
(scriptDataService.invoke as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(process));
282+
283+
component.bitstreamFromUrl = ' https://example.org/file.pdf ';
284+
component.bitstreamName = ' downloaded.pdf ';
285+
component.addBitstreamFromUrl(closeModal);
286+
287+
expect(scriptDataService.invoke).toHaveBeenCalledWith('file-downloader', [
288+
{ name: '-u', value: 'https://example.org/file.pdf' },
289+
{ name: '-w', value: '1234' },
290+
{ name: '-n', value: 'downloaded.pdf' },
291+
], []);
292+
});
293+
294+
it('should navigate to process detail and close modal on add from URL success', () => {
295+
const closeModal = jasmine.createSpy('closeModal');
296+
const router = TestBed.inject(Router);
297+
spyOn(router, 'navigateByUrl').and.callThrough();
298+
(scriptDataService.invoke as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({ processId: 202 } as any));
299+
300+
component.bitstreamFromUrl = 'https://example.org/file.pdf';
301+
component.addBitstreamFromUrl(closeModal);
302+
303+
expect(notificationsServiceStub.success).toHaveBeenCalled();
304+
expect(closeModal).toHaveBeenCalledWith('ok');
305+
expect(router.navigateByUrl).toHaveBeenCalledWith(getProcessDetailRoute('202'));
306+
});
307+
308+
it('should show error notification on add from URL failure', () => {
309+
const closeModal = jasmine.createSpy('closeModal');
310+
(scriptDataService.invoke as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Error', 500));
311+
312+
component.bitstreamFromUrl = 'https://example.org/file.pdf';
313+
component.addBitstreamFromUrl(closeModal);
314+
315+
expect(notificationsServiceStub.error).toHaveBeenCalled();
316+
expect(closeModal).not.toHaveBeenCalled();
317+
});
318+
254319
describe('on discard confirmation', () => {
255320
beforeEach((done) => {
256321
mockDataService.delete.and.returnValue(of(true));

src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
Input,
66
OnInit,
77
} from '@angular/core';
8+
import { FormsModule } from '@angular/forms';
89
import {
910
Router,
1011
RouterLink,
@@ -27,6 +28,7 @@ import { AuthorizationDataService } from 'src/app/core/data/feature-authorizatio
2728
import { AuthService } from '../../../core/auth/auth.service';
2829
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
2930
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
31+
import { ScriptDataService } from '../../../core/data/processes/script-data.service';
3032
import { RemoteData } from '../../../core/data/remote-data';
3133
import { GetRequest } from '../../../core/data/request.models';
3234
import { RequestService } from '../../../core/data/request.service';
@@ -40,10 +42,16 @@ import {
4042
import { SearchService } from '../../../core/shared/search/search.service';
4143
import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model';
4244
import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service';
45+
import { getProcessDetailRoute } from '../../../process-page/process-page-routing.paths';
46+
import { Process } from '../../../process-page/processes/process.model';
47+
import { ProcessParameter } from '../../../process-page/processes/process-parameter.model';
4348
import { getWorkspaceItemViewRoute } from '../../../workspaceitems-edit-page/workspaceitems-edit-page-routing-paths';
49+
import { BtnDisabledDirective } from '../../btn-disabled.directive';
4450
import { NotificationsService } from '../../notifications/notifications.service';
4551
import { MyDSpaceActionsComponent } from '../mydspace-actions';
4652

53+
const FILE_DOWNLOADER_SCRIPT_NAME = 'file-downloader';
54+
4755
/**
4856
* This component represents actions related to WorkspaceItem object.
4957
*/
@@ -53,6 +61,8 @@ import { MyDSpaceActionsComponent } from '../mydspace-actions';
5361
templateUrl: './workspaceitem-actions.component.html',
5462
imports: [
5563
AsyncPipe,
64+
BtnDisabledDirective,
65+
FormsModule,
5666
NgbTooltip,
5767
RouterLink,
5868
TranslateModule,
@@ -79,6 +89,14 @@ export class WorkspaceitemActionsComponent extends MyDSpaceActionsComponent<Work
7989
*/
8090
canEditItem$: Observable<boolean>;
8191

92+
canUseFileDownloader$: Observable<boolean>;
93+
94+
public processingAddFromUrl$ = new BehaviorSubject<boolean>(false);
95+
96+
public bitstreamFromUrl = '';
97+
98+
public bitstreamName = '';
99+
82100
/**
83101
* A boolean representing if a share operation is pending. It is used to show/hide the spinner.
84102
*/
@@ -106,6 +124,7 @@ export class WorkspaceitemActionsComponent extends MyDSpaceActionsComponent<Work
106124
public authorizationService: AuthorizationDataService,
107125
protected halService: HALEndpointService,
108126
protected rdbService: RemoteDataBuildService,
127+
protected scriptDataService: ScriptDataService,
109128
) {
110129
super(WorkspaceItem.type, injector, router, notificationsService, translate, searchService, requestService);
111130

@@ -134,6 +153,8 @@ export class WorkspaceitemActionsComponent extends MyDSpaceActionsComponent<Work
134153
ngOnInit(): void {
135154
const activeEPerson$ = this.authService.getAuthenticatedUserFromStore();
136155

156+
this.canUseFileDownloader$ = this.scriptDataService.scriptWithNameExistsAndCanExecute(FILE_DOWNLOADER_SCRIPT_NAME);
157+
137158
this.canEditItem$ = activeEPerson$.pipe(
138159
switchMap((eperson) => {
139160
return this.object?.item.pipe(
@@ -146,6 +167,55 @@ export class WorkspaceitemActionsComponent extends MyDSpaceActionsComponent<Work
146167
}));
147168
}
148169

170+
openAddBitstreamFromUrlModal(content): void {
171+
this.bitstreamFromUrl = '';
172+
this.bitstreamName = '';
173+
this.processingAddFromUrl$.next(false);
174+
this.modalService.open(content);
175+
}
176+
177+
addBitstreamFromUrl(closeModal?: (value?: any) => void): void {
178+
const normalizedUrl = this.bitstreamFromUrl?.trim();
179+
const normalizedName = this.bitstreamName?.trim();
180+
181+
if (!normalizedUrl) {
182+
return;
183+
}
184+
185+
const parameters: ProcessParameter[] = [
186+
{ name: '-u', value: normalizedUrl },
187+
{ name: '-w', value: this.object.id },
188+
];
189+
190+
if (normalizedName) {
191+
parameters.push({ name: '-n', value: normalizedName });
192+
}
193+
194+
this.processingAddFromUrl$.next(true);
195+
this.scriptDataService.invoke(FILE_DOWNLOADER_SCRIPT_NAME, parameters, [])
196+
.pipe(getFirstCompletedRemoteData())
197+
.subscribe((rd: RemoteData<Process>) => {
198+
this.processingAddFromUrl$.next(false);
199+
if (rd.hasSucceeded) {
200+
this.notificationsService.success(
201+
this.translate.get('process.new.notification.success.title'),
202+
this.translate.get('process.new.notification.success.content'),
203+
);
204+
if (closeModal) {
205+
closeModal('ok');
206+
}
207+
if (rd.payload?.processId) {
208+
void this.router.navigateByUrl(getProcessDetailRoute(String(rd.payload.processId)));
209+
}
210+
} else {
211+
this.notificationsService.error(
212+
this.translate.get('process.new.notification.error.title'),
213+
this.translate.get('process.new.notification.error.content'),
214+
);
215+
}
216+
});
217+
}
218+
149219
/**
150220
* Init the target object
151221
*

src/assets/i18n/cs.json5

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10064,6 +10064,33 @@
1006410064
// "submission.workflow.generic.share-submission.tooltip": "Share submission",
1006510065
"submission.workflow.generic.share-submission.tooltip": "Sdílet příspěvek",
1006610066

10067+
// "submission.workflow.generic.add-url-bitstream": "Add bitstream from URL",
10068+
"submission.workflow.generic.add-url-bitstream": "Přidat bitstream z URL",
10069+
10070+
// "submission.workflow.generic.add-url-bitstream.tooltip": "Add a new bitstream to this workspace item using a URL",
10071+
"submission.workflow.generic.add-url-bitstream.tooltip": "Přidat nový bitstream do této položky pracovního prostoru pomocí URL",
10072+
10073+
// "submission.workflow.generic.add-url-bitstream.modal.title": "Add bitstream from URL",
10074+
"submission.workflow.generic.add-url-bitstream.modal.title": "Přidat bitstream z URL",
10075+
10076+
// "submission.workflow.generic.add-url-bitstream.url.label": "File URL",
10077+
"submission.workflow.generic.add-url-bitstream.url.label": "URL souboru",
10078+
10079+
// "submission.workflow.generic.add-url-bitstream.url.required": "URL is required",
10080+
"submission.workflow.generic.add-url-bitstream.url.required": "URL je povinná",
10081+
10082+
// "submission.workflow.generic.add-url-bitstream.name.label": "Bitstream name (optional)",
10083+
"submission.workflow.generic.add-url-bitstream.name.label": "Název bitstreamu (volitelné)",
10084+
10085+
// "submission.workflow.generic.add-url-bitstream.name.placeholder": "e.g. image.png",
10086+
"submission.workflow.generic.add-url-bitstream.name.placeholder": "např. image.png",
10087+
10088+
// "submission.workflow.generic.add-url-bitstream.cancel": "Cancel",
10089+
"submission.workflow.generic.add-url-bitstream.cancel": "Zrušit",
10090+
10091+
// "submission.workflow.generic.add-url-bitstream.submit": "Add",
10092+
"submission.workflow.generic.add-url-bitstream.submit": "Přidat",
10093+
1006710094
// "submission.workflow.generic.view": "View",
1006810095
"submission.workflow.generic.view": "Zobrazit",
1006910096

src/assets/i18n/en.json5

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7760,6 +7760,15 @@
77607760
"submission.sections.clarin-notice.error": "Please confirm that you have read the Notice.",
77617761
"submission.workflow.generic.share-submission": "Share submission",
77627762
"submission.workflow.generic.share-submission.tooltip": "Share submission",
7763+
"submission.workflow.generic.add-url-bitstream": "Add bitstream from URL",
7764+
"submission.workflow.generic.add-url-bitstream.tooltip": "Add a new bitstream to this workspace item using a URL",
7765+
"submission.workflow.generic.add-url-bitstream.modal.title": "Add bitstream from URL",
7766+
"submission.workflow.generic.add-url-bitstream.url.label": "File URL",
7767+
"submission.workflow.generic.add-url-bitstream.url.required": "URL is required",
7768+
"submission.workflow.generic.add-url-bitstream.name.label": "Bitstream name (optional)",
7769+
"submission.workflow.generic.add-url-bitstream.name.placeholder": "e.g. image.png",
7770+
"submission.workflow.generic.add-url-bitstream.cancel": "Cancel",
7771+
"submission.workflow.generic.add-url-bitstream.submit": "Add",
77637772
"submission.workflow.share-submission.email.successful": "The email with the share link has been sent successfully",
77647773
"submission.workflow.share-submission.email.error": "Cannot send the email with share link",
77657774
"language.english": "English",

0 commit comments

Comments
 (0)