feat(DICOM Upload): Added new DICOM upload dialogue launched from the worklist page (#3326)

* feat(DICOM Upload)
OHIF #3297
- DicomWebDataSource.store.dicom now accepts an ArrayBuffer of data sets to store
- DicomWebDataSource.store.dicom now also accepts optional callbacks to track upload and an AbortSignal object to cancel upload
- Added DicomFileUploader class that performs and tracks the upload of a DICOM file
- Added various UI pieces for the upload: DicomUpload, DicomUploadProgress, DicomUploadProgressItem
- ProgressLoadingBar was extracted from LoadingIndicatorProgress so it can be reused
- Modal dialogues can now optionally prevent an outside click from closing the Modal

* Passing an XMLHttpRequest to the dataSource.store.dicom method instead of callbacks and AbortSignal.
Cleanup of various UI pieces to minimize code and increase readability.

* Made the DicomUpload component a customization module exported by the cornerstone extension.

* Exposed a copy of the data source configuration via the IWebApiDataSource interface.
Added dicomUploadEnabled to a data source's configuration.

* Code clean up.

* Upgraded the dicomweb-client version to one that provides the ability to
pass a custom HTTP request. DICOM upload uses that custom HTTP request
to track progress and cancel requests.

* Distinguished between failed and cancelled uploads.

* Allow no selection in the upload dialogue.
Fixed the styling of various progress information so that everything aligns.

* Switched from cornerstone wado image loader to cornerstone dicom image loader for DICOM upload.

* Added special cancelled icon to differentiate from failed.

* Added a bit of spacing between the upload progress bar and percentage.

* Fixed minor issue with upload rejection.

* Performance improvement for cancel all uploads:
- use React memo for each upload item progress (row)
- do not await each request of a cancel all

* Fixed various padding/spacing for the DICOM upload drop zone component.
Changed the border dashing for the DICOM upload drop zone to be a background image gradient.
Added hover and active effects to the 'Cancel All Uploads' text.
This commit is contained in:
Joe Boccanfuso authored and GitHub committed 2023-04-28 16:13:47 -04:00
1 parent f377153b60
commit 66f6e3eade
32 files changed
+1126 -350

No files matched your search

@@ -0,0 +1,220 @@
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import { PubSubService } from '@ohif/core';
export const EVENTS = {
PROGRESS: 'event:DicomFileUploader:progress',
};
export interface DicomFileUploaderEvent {
fileId: number;
}
export interface DicomFileUploaderProgressEvent extends DicomFileUploaderEvent {
percentComplete: number;
}
export enum UploadStatus {
NotStarted,
InProgress,
Success,
Failed,
Cancelled,
}
type CancelOrFailed = UploadStatus.Cancelled | UploadStatus.Failed;
export class UploadRejection {
message: string;
status: CancelOrFailed;
constructor(status: CancelOrFailed, message: string) {
this.message = message;
this.status = status;
}
}
export default class DicomFileUploader extends PubSubService {
private _file;
private _fileId;
private _dataSource;
private _loadPromise;
private _abortController = new AbortController();
private _status: UploadStatus = UploadStatus.NotStarted;
private _percentComplete = 0;
constructor(file, dataSource) {
super(EVENTS);
this._file = file;
this._fileId = dicomImageLoader.wadouri.fileManager.add(file);
this._dataSource = dataSource;
}
getFileId(): string {
return this._fileId;
}
getFileName(): string {
return this._file.name;
}
getFileSize(): number {
return this._file.size;
}
cancel(): void {
this._abortController.abort();
}
getStatus(): UploadStatus {
return this._status;
}
getPercentComplete(): number {
return this._percentComplete;
}
async load(): Promise<void> {
if (this._loadPromise) {
// Already started loading, return the load promise.
return this._loadPromise;
}
this._loadPromise = new Promise<void>((resolve, reject) => {
// The upload listeners: fire progress events and/or settle the promise.
const uploadCallbacks = {
progress: evt => {
if (!evt.lengthComputable) {
// Progress computation is not possible.
return;
}
this._status = UploadStatus.InProgress;
this._percentComplete = Math.round((100 * evt.loaded) / evt.total);
this._broadcastEvent(EVENTS.PROGRESS, {
fileId: this._fileId,
percentComplete: this._percentComplete,
});
},
timeout: () => {
this._reject(
reject,
new UploadRejection(UploadStatus.Failed, 'The request timed out.')
);
},
abort: () => {
this._reject(
reject,
new UploadRejection(UploadStatus.Cancelled, 'Cancelled')
);
},
error: () => {
this._reject(
reject,
new UploadRejection(UploadStatus.Failed, 'The request failed.')
);
},
};
// First try to load the file.
dicomImageLoader.wadouri
.loadFileRequest(this._fileId)
.then(dicomFile => {
if (this._abortController.signal.aborted) {
this._reject(
reject,
new UploadRejection(UploadStatus.Cancelled, 'Cancelled')
);
return;
}
if (!this._checkDicomFile(dicomFile)) {
// The file is not DICOM
this._reject(
reject,
new UploadRejection(
UploadStatus.Failed,
'Not a valid DICOM file.'
)
);
return;
}
const request = new XMLHttpRequest();
this._addRequestCallbacks(request, uploadCallbacks);
// Do the actual upload by supplying the DICOM file and upload callbacks/listeners.
return this._dataSource.store
.dicom(dicomFile, request)
.then(() => {
this._status = UploadStatus.Success;
resolve();
})
.catch(reason => {
this._reject(reject, reason);
});
})
.catch(reason => {
this._reject(reject, reason);
});
});
return this._loadPromise;
}
private _isRejected(): boolean {
return (
this._status === UploadStatus.Failed ||
this._status === UploadStatus.Cancelled
);
}
private _reject(reject: (reason?: any) => void, reason: any) {
if (this._isRejected()) {
return;
}
if (reason instanceof UploadRejection) {
this._status = reason.status;
reject(reason);
return;
}
this._status = UploadStatus.Failed;
if (reason.message) {
reject(new UploadRejection(UploadStatus.Failed, reason.message));
return;
}
reject(new UploadRejection(UploadStatus.Failed, reason));
}
private _addRequestCallbacks(request: XMLHttpRequest, uploadCallbacks) {
const abortCallback = () => request.abort();
this._abortController.signal.addEventListener('abort', abortCallback);
for (const [eventName, callback] of Object.entries(uploadCallbacks)) {
request.upload.addEventListener(eventName, callback);
}
const cleanUpCallback = () => {
this._abortController.signal.removeEventListener('abort', abortCallback);
for (const [eventName, callback] of Object.entries(uploadCallbacks)) {
request.upload.removeEventListener(eventName, callback);
}
request.removeEventListener('loadend', cleanUpCallback);
};
request.addEventListener('loadend', cleanUpCallback);
}
private _checkDicomFile(arrayBuffer: ArrayBuffer) {
if (arrayBuffer.length <= 132) return false;
const arr = new Uint8Array(arrayBuffer.slice(128, 132));
// bytes from 128 to 132 must be "DICM"
return Array.from('DICM').every((char, i) => char.charCodeAt(0) === arr[i]);
}
}