Skip to content

Commit b33e456

Browse files
milanmajchrakclaude
andcommitted
UoE/Bitstream name in upload notifications
Upload success and failure toasts in the submission form now name the file they refer to ("File "report.pdf" uploaded successfully") instead of the anonymous "Upload successful" / "Upload failed", so a submitter dropping several files can tell which one each toast is about. The uploader gains an additive `onCompleteItemWithFile` output carrying the parsed response plus the client-side file name; the legacy `onCompleteItem` is retained and still emits the bare parsed body first, byte-identically, so the five other uploader consumers are untouched. `onUploadError` is retyped from `any` to the existing `UploaderError`. A single `getNotificationContent` helper is the only place an upload notification key literal appears. The CLARIN client-side size-limit rejection is preserved exactly: its discriminator still compares against a one-argument `translate.instant(key)` and still runs first, and its message is emitted un-interpolated and without a file name. The error handler resolves the name from both emitted item shapes (FileItem from onErrorItem, FileLikeObject from onWhenAddingFileFailed). Locales that have not translated the two new keys render the generic message via the `default` interpolate param that MissingTranslationHelper already honours, rather than a raw dotted key. cs.json5 carries human translations. Ported from dataquest-dev#24. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ac78637 commit b33e456

8 files changed

Lines changed: 536 additions & 19 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* An interface that represents a completed single-file upload, carrying both the
3+
* parsed response body and the client-side file name of the file that completed.
4+
*/
5+
export interface UploaderCompleteEvent {
6+
/**
7+
* The parsed response body (e.g. the submission object returned by REST)
8+
*/
9+
response: any;
10+
11+
/**
12+
* The client-side name of the file that completed uploading. Present only when a
13+
* non-empty file name is known — an empty file name is never emitted, so the presence
14+
* of this key means a usable name is available. Whitespace-only names are not trimmed.
15+
*/
16+
fileName?: string;
17+
}

src/app/shared/upload/uploader/uploader.component.spec.ts

Lines changed: 228 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { DragService } from '../../../core/drag.service';
88
import { UploaderOptions } from './uploader-options.model';
99
import { UploaderComponent } from './uploader.component';
1010
import { FileUploadModule } from 'ng2-file-upload';
11-
import { TranslateModule } from '@ngx-translate/core';
11+
import { TranslateModule, TranslateService } from '@ngx-translate/core';
1212
import { createTestComponent } from '../../testing/utils.test';
1313
import { HttpXsrfTokenExtractor } from '@angular/common/http';
1414
import { CookieService } from '../../../core/services/cookie.service';
@@ -69,6 +69,233 @@ describe('Chips component', () => {
6969
expect(app).toBeDefined();
7070
}));
7171

72+
it('should emit both onCompleteItem and onCompleteItemWithFile on a completed upload', inject([UploaderComponent], (app: UploaderComponent) => {
73+
app.uploadFilesOptions = Object.assign(new UploaderOptions(), {
74+
url: 'http://test',
75+
authToken: null,
76+
disableMultipart: false,
77+
itemAlias: null,
78+
});
79+
app.ngOnInit();
80+
app.ngAfterViewInit();
81+
82+
spyOn(app.onCompleteItem, 'emit');
83+
spyOn(app.onCompleteItemWithFile, 'emit');
84+
85+
const parsed = { foo: 'bar' };
86+
app.uploader.onCompleteItem({ file: { name: 'test.pdf' } } as any, JSON.stringify(parsed), 200, {});
87+
88+
expect(app.onCompleteItem.emit).toHaveBeenCalledWith(parsed);
89+
expect(app.onCompleteItem.emit).toHaveBeenCalledTimes(1);
90+
expect(app.onCompleteItemWithFile.emit).toHaveBeenCalledWith({ response: parsed, fileName: 'test.pdf' });
91+
}));
92+
93+
it('should not emit either completion output when the response body is empty', inject([UploaderComponent], (app: UploaderComponent) => {
94+
app.uploadFilesOptions = Object.assign(new UploaderOptions(), {
95+
url: 'http://test',
96+
authToken: null,
97+
disableMultipart: false,
98+
itemAlias: null,
99+
});
100+
app.ngOnInit();
101+
app.ngAfterViewInit();
102+
103+
spyOn(app.onCompleteItem, 'emit');
104+
spyOn(app.onCompleteItemWithFile, 'emit');
105+
106+
app.uploader.onCompleteItem({ file: { name: 'test.pdf' } } as any, '', 204, {});
107+
108+
expect(app.onCompleteItem.emit).not.toHaveBeenCalled();
109+
expect(app.onCompleteItemWithFile.emit).not.toHaveBeenCalled();
110+
}));
111+
112+
it('should omit fileName from the completion event when the item is undefined', inject([UploaderComponent], (app: UploaderComponent) => {
113+
app.uploadFilesOptions = Object.assign(new UploaderOptions(), {
114+
url: 'http://test',
115+
authToken: null,
116+
disableMultipart: false,
117+
itemAlias: null,
118+
});
119+
app.ngOnInit();
120+
app.ngAfterViewInit();
121+
122+
spyOn(app.onCompleteItemWithFile, 'emit');
123+
124+
const parsed = { foo: 'bar' };
125+
app.uploader.onCompleteItem(undefined, JSON.stringify(parsed), 200, {});
126+
127+
const arg = (app.onCompleteItemWithFile.emit as jasmine.Spy).calls.mostRecent().args[0];
128+
expect(app.onCompleteItemWithFile.emit).toHaveBeenCalledWith({ response: parsed });
129+
expect(Object.keys(arg)).toEqual(['response']);
130+
expect('fileName' in arg).toBeFalse();
131+
}));
132+
133+
it('should omit fileName from the completion event when the item has no file', inject([UploaderComponent], (app: UploaderComponent) => {
134+
app.uploadFilesOptions = Object.assign(new UploaderOptions(), {
135+
url: 'http://test',
136+
authToken: null,
137+
disableMultipart: false,
138+
itemAlias: null,
139+
});
140+
app.ngOnInit();
141+
app.ngAfterViewInit();
142+
143+
spyOn(app.onCompleteItemWithFile, 'emit');
144+
145+
const parsed = { foo: 'bar' };
146+
app.uploader.onCompleteItem({} as any, JSON.stringify(parsed), 200, {});
147+
148+
const arg = (app.onCompleteItemWithFile.emit as jasmine.Spy).calls.mostRecent().args[0];
149+
expect(app.onCompleteItemWithFile.emit).toHaveBeenCalledWith({ response: parsed });
150+
expect(Object.keys(arg)).toEqual(['response']);
151+
expect('fileName' in arg).toBeFalse();
152+
}));
153+
154+
it('should omit fileName from the completion event when the file has no name', inject([UploaderComponent], (app: UploaderComponent) => {
155+
app.uploadFilesOptions = Object.assign(new UploaderOptions(), {
156+
url: 'http://test',
157+
authToken: null,
158+
disableMultipart: false,
159+
itemAlias: null,
160+
});
161+
app.ngOnInit();
162+
app.ngAfterViewInit();
163+
164+
spyOn(app.onCompleteItemWithFile, 'emit');
165+
166+
const parsed = { foo: 'bar' };
167+
app.uploader.onCompleteItem({ file: {} } as any, JSON.stringify(parsed), 200, {});
168+
169+
const arg = (app.onCompleteItemWithFile.emit as jasmine.Spy).calls.mostRecent().args[0];
170+
expect(app.onCompleteItemWithFile.emit).toHaveBeenCalledWith({ response: parsed });
171+
expect(Object.keys(arg)).toEqual(['response']);
172+
expect('fileName' in arg).toBeFalse();
173+
}));
174+
175+
it('should omit fileName from the completion event when the file name is an empty string', inject([UploaderComponent], (app: UploaderComponent) => {
176+
app.uploadFilesOptions = Object.assign(new UploaderOptions(), {
177+
url: 'http://test',
178+
authToken: null,
179+
disableMultipart: false,
180+
itemAlias: null,
181+
});
182+
app.ngOnInit();
183+
app.ngAfterViewInit();
184+
185+
spyOn(app.onCompleteItemWithFile, 'emit');
186+
187+
const parsed = { foo: 'bar' };
188+
app.uploader.onCompleteItem({ file: { name: '' } } as any, JSON.stringify(parsed), 200, {});
189+
190+
const arg = (app.onCompleteItemWithFile.emit as jasmine.Spy).calls.mostRecent().args[0];
191+
expect(app.onCompleteItemWithFile.emit).toHaveBeenCalledWith({ response: parsed });
192+
expect(Object.keys(arg)).toEqual(['response']);
193+
expect('fileName' in arg).toBeFalse();
194+
}));
195+
196+
it('should keep a whitespace-only file name on the completion event', inject([UploaderComponent], (app: UploaderComponent) => {
197+
app.uploadFilesOptions = Object.assign(new UploaderOptions(), {
198+
url: 'http://test',
199+
authToken: null,
200+
disableMultipart: false,
201+
itemAlias: null,
202+
});
203+
app.ngOnInit();
204+
app.ngAfterViewInit();
205+
206+
spyOn(app.onCompleteItemWithFile, 'emit');
207+
208+
const parsed = { foo: 'bar' };
209+
app.uploader.onCompleteItem({ file: { name: ' ' } } as any, JSON.stringify(parsed), 200, {});
210+
211+
expect(app.onCompleteItemWithFile.emit).toHaveBeenCalledWith({ response: parsed, fileName: ' ' });
212+
}));
213+
214+
it('should emit a distinct file name for each of two sequential completed uploads', inject([UploaderComponent], (app: UploaderComponent) => {
215+
app.uploadFilesOptions = Object.assign(new UploaderOptions(), {
216+
url: 'http://test',
217+
authToken: null,
218+
disableMultipart: false,
219+
itemAlias: null,
220+
});
221+
app.ngOnInit();
222+
app.ngAfterViewInit();
223+
224+
spyOn(app.onCompleteItemWithFile, 'emit');
225+
226+
app.uploader.onCompleteItem({ file: { name: 'first.pdf' } } as any, JSON.stringify({ n: 1 }), 200, {});
227+
app.uploader.onCompleteItem({ file: { name: 'second.pdf' } } as any, JSON.stringify({ n: 2 }), 200, {});
228+
229+
const emitSpy = app.onCompleteItemWithFile.emit as jasmine.Spy;
230+
expect(emitSpy).toHaveBeenCalledTimes(2);
231+
expect(emitSpy.calls.argsFor(0)[0]).toEqual({ response: { n: 1 }, fileName: 'first.pdf' });
232+
expect(emitSpy.calls.argsFor(1)[0]).toEqual({ response: { n: 2 }, fileName: 'second.pdf' });
233+
}));
234+
235+
it('should emit onUploadError with the item, response, status and headers of the failed upload', inject([UploaderComponent], (app: UploaderComponent) => {
236+
app.uploadFilesOptions = Object.assign(new UploaderOptions(), {
237+
url: 'http://test',
238+
authToken: null,
239+
disableMultipart: false,
240+
itemAlias: null,
241+
});
242+
app.ngOnInit();
243+
app.ngAfterViewInit();
244+
245+
spyOn(app.onUploadError, 'emit');
246+
247+
app.uploader.onErrorItem({ file: { name: 'broken.zip' } } as any, 'boom', 500, {});
248+
249+
expect(app.onUploadError.emit).toHaveBeenCalledWith({
250+
item: { file: { name: 'broken.zip' } },
251+
response: 'boom',
252+
status: 500,
253+
headers: {},
254+
});
255+
}));
256+
257+
it('should emit the un-interpolated size-limit message when a file exceeds the maximum upload size', inject([UploaderComponent], (app: UploaderComponent) => {
258+
app.uploadFilesOptions = Object.assign(new UploaderOptions(), {
259+
url: 'http://test',
260+
authToken: null,
261+
disableMultipart: false,
262+
itemAlias: null,
263+
});
264+
app.ngOnInit();
265+
app.ngAfterViewInit();
266+
267+
const instantSpy = spyOn(TestBed.inject(TranslateService), 'instant').and.returnValue('SIZE-LIMIT-MSG');
268+
spyOn(app.onUploadError, 'emit');
269+
270+
app.uploader.options.maxFileSize = 1024;
271+
app.uploader.onWhenAddingFileFailed({ name: 'big.zip', size: 2048 } as any, null, app.uploader.options);
272+
273+
expect(instantSpy).toHaveBeenCalledWith('submission.sections.upload.upload-failed.size-limit-exceeded');
274+
expect(app.onUploadError.emit).toHaveBeenCalledWith(jasmine.objectContaining({
275+
status: 400,
276+
response: 'SIZE-LIMIT-MSG',
277+
}));
278+
}));
279+
280+
it('should not pass interpolation params to the size-limit instant() call', inject([UploaderComponent], (app: UploaderComponent) => {
281+
app.uploadFilesOptions = Object.assign(new UploaderOptions(), {
282+
url: 'http://test',
283+
authToken: null,
284+
disableMultipart: false,
285+
itemAlias: null,
286+
});
287+
app.ngOnInit();
288+
app.ngAfterViewInit();
289+
290+
const instantSpy = spyOn(TestBed.inject(TranslateService), 'instant').and.returnValue('SIZE-LIMIT-MSG');
291+
292+
app.uploader.options.maxFileSize = 1024;
293+
app.uploader.onWhenAddingFileFailed({ name: 'big.zip', size: 2048 } as any, null, app.uploader.options);
294+
295+
expect(instantSpy.calls.count()).toBe(1);
296+
expect(instantSpy.calls.mostRecent().args.length).toBe(1);
297+
}));
298+
72299
});
73300

74301
// declare a test component

src/app/shared/upload/uploader/uploader.component.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { FileUploader } from 'ng2-file-upload';
1515
import uniqueId from 'lodash/uniqueId';
1616
import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to';
1717

18+
import { UploaderCompleteEvent } from './uploader-complete-event.model';
19+
import { UploaderError } from './uploader-error.model';
1820
import { UploaderOptions } from './uploader-options.model';
1921
import { hasValue, isNotEmpty, isUndefined } from '../../empty.util';
2022
import { UploaderProperties } from './uploader-properties.model';
@@ -89,10 +91,16 @@ export class UploaderComponent implements OnInit, AfterViewInit {
8991
*/
9092
@Output() onCompleteItem: EventEmitter<any> = new EventEmitter<any>();
9193

94+
/**
95+
* The function to call when upload is completed, carrying the parsed response together with the
96+
* client-side file name. Emitted alongside {@link onCompleteItem} so existing consumers are unaffected.
97+
*/
98+
@Output() onCompleteItemWithFile: EventEmitter<UploaderCompleteEvent> = new EventEmitter<UploaderCompleteEvent>();
99+
92100
/**
93101
* The function to call on error occurred
94102
*/
95-
@Output() onUploadError: EventEmitter<any> = new EventEmitter<any>();
103+
@Output() onUploadError: EventEmitter<UploaderError> = new EventEmitter<UploaderError>();
96104

97105
/**
98106
* The function to call when a file is selected
@@ -201,6 +209,8 @@ export class UploaderComponent implements OnInit, AfterViewInit {
201209
if (isNotEmpty(response)) {
202210
const responsePath = JSON.parse(response);
203211
this.onCompleteItem.emit(responsePath);
212+
const fileName = item?.file?.name;
213+
this.onCompleteItemWithFile.emit(isNotEmpty(fileName) ? { response: responsePath, fileName } : { response: responsePath });
204214
}
205215
};
206216
this.uploader.onErrorItem = (item: any, response: any, status: any, headers: any) => {

src/app/submission/form/submission-upload-files/submission-upload-files.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
[enableDragOverDocument]="enableDragOverDocument"
55
[onBeforeUpload]="onBeforeUpload"
66
[uploadFilesOptions]="uploadFilesOptions"
7-
(onCompleteItem)="onCompleteItem($event)"
7+
(onCompleteItemWithFile)="onCompleteItem($event)"
88
(onUploadError)="onUploadError($event)"></ds-uploader>

0 commit comments

Comments
 (0)