feat: Issue 879 viewer route query param not filtering but promoting (#1141)
* feat: 🎸 Filter by url query param for seriesInstnaceUID * fix: Set SR viewport as active by interaction (#1118) * fix: Set SR viewport as active by interaction * quick fix * (eslint) add "before" as global variables * add data-cy * add data-cy * create E2E test * (E2E) create custom command to set layout size * remove .only e2e * remove throttle for onScroll * feat: 🎸 Code review in progress Code review. Move retrieveMEtadata load to separate folders. Some minor code clean up * feat: 🎸 Code review. Missing changes from previous commit * feat: 🎸 Code review missing changes from previous commit When sorting, Criteria for instance must use instanceNumber and not instancesNumber * feat: 🎸 Code review. Add more jsdoc info * feat: 🎸 Code review. Prettify changed code * feat: 🎸 Instead of filtering do promote. WIP * feat: 🎸 Fix minor issue. Allow promote or filter * feat: 🎸 Changing component to functional component * Merge from master Part1/2 Conflicts solved: both added: core/src/studies/services/wado/retrieveMetadataLoader.js both added: core/src/studies/services/wado/retrieveMetadataLoaderAsync.js both added: core/src/studies/services/wado/retrieveMetadataLoaderSync.js both modified: viewer/src/connectedComponents/ViewerRetrieveStudyData.js both modified: viewer/src/routes/ViewerRouting.js * Merge process from master Part 1/2 Missing files from previous commit * feat: 🎸 Add cancelable promises to cut async methods * feat: 🎸 Missing changes from previous merge process * feat: 🎸 Missing changes from previous merge process
This commit is contained in:
parent
c77a2ef5c9
commit
b17f753e62
@ -40,7 +40,7 @@ export default class RetrieveMetadataLoader {
|
||||
if (result && result.length) {
|
||||
break; // closes iterator in case data is retrieved successfully
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (loaders.next().done && !result) {
|
||||
@ -51,8 +51,8 @@ export default class RetrieveMetadataLoader {
|
||||
}
|
||||
|
||||
// Methods to be overwrite
|
||||
async configLoad() {}
|
||||
async preLoad() {}
|
||||
async load(preLoadData) {}
|
||||
async posLoad(loadData) {}
|
||||
async configLoad() { }
|
||||
async preLoad() { }
|
||||
async load(preLoadData) { }
|
||||
async posLoad(loadData) { }
|
||||
}
|
||||
|
||||
413
platform/core/src/studies/studyUtils.js
Normal file
413
platform/core/src/studies/studyUtils.js
Normal file
@ -0,0 +1,413 @@
|
||||
import DICOMWeb from '../DICOMWeb/';
|
||||
|
||||
const WADOProxy = {
|
||||
convertURL: (url, server) => {
|
||||
// TODO: Remove all WADOProxy stuff from this file
|
||||
return url;
|
||||
},
|
||||
};
|
||||
function parseFloatArray(obj) {
|
||||
const result = [];
|
||||
|
||||
if (!obj) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const objs = obj.split('\\');
|
||||
for (let i = 0; i < objs.length; i++) {
|
||||
result.push(parseFloat(objs[i]));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plain JS object that describes a study (a study descriptor object)
|
||||
* @param {Object} server Object with server configuration parameters
|
||||
* @param {Object} aSopInstance a SOP Instance from which study information will be added
|
||||
*/
|
||||
function createStudy(server, aSopInstance) {
|
||||
// TODO: Pass a reference ID to the server instead of including the URLs here
|
||||
return {
|
||||
seriesList: [],
|
||||
seriesMap: Object.create(null),
|
||||
seriesLoader: null,
|
||||
wadoUriRoot: server.wadoUriRoot,
|
||||
wadoRoot: server.wadoRoot,
|
||||
qidoRoot: server.qidoRoot,
|
||||
patientName: DICOMWeb.getName(aSopInstance['00100010']),
|
||||
patientId: DICOMWeb.getString(aSopInstance['00100020']),
|
||||
patientAge: DICOMWeb.getNumber(aSopInstance['00101010']),
|
||||
patientSize: DICOMWeb.getNumber(aSopInstance['00101020']),
|
||||
patientWeight: DICOMWeb.getNumber(aSopInstance['00101030']),
|
||||
accessionNumber: DICOMWeb.getString(aSopInstance['00080050']),
|
||||
studyDate: DICOMWeb.getString(aSopInstance['00080020']),
|
||||
modalities: DICOMWeb.getString(aSopInstance['00080061']),
|
||||
studyDescription: DICOMWeb.getString(aSopInstance['00081030']),
|
||||
imageCount: DICOMWeb.getString(aSopInstance['00201208']),
|
||||
studyInstanceUid: DICOMWeb.getString(aSopInstance['0020000D']),
|
||||
institutionName: DICOMWeb.getString(aSopInstance['00080080']),
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns a WADO url for an instance
|
||||
*
|
||||
* @param studyInstanceUid
|
||||
* @param seriesInstanceUid
|
||||
* @param sopInstanceUid
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildInstanceWadoUrl(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
) {
|
||||
// TODO: This can be removed, since DICOMWebClient has the same function. Not urgent, though
|
||||
const params = [];
|
||||
|
||||
params.push('requestType=WADO');
|
||||
params.push(`studyUID=${studyInstanceUid}`);
|
||||
params.push(`seriesUID=${seriesInstanceUid}`);
|
||||
params.push(`objectUID=${sopInstanceUid}`);
|
||||
params.push('contentType=application/dicom');
|
||||
params.push('transferSyntax=*');
|
||||
|
||||
const paramString = params.join('&');
|
||||
|
||||
return `${server.wadoUriRoot}?${paramString}`;
|
||||
}
|
||||
|
||||
function buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
) {
|
||||
return `${server.wadoRoot}/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`;
|
||||
}
|
||||
|
||||
function buildInstanceFrameWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid,
|
||||
frame
|
||||
) {
|
||||
const baseWadoRsUri = buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
frame = frame != null || 1;
|
||||
|
||||
return `${baseWadoRsUri}/frames/${frame}`;
|
||||
}
|
||||
|
||||
function getFrameIncrementPointer(element) {
|
||||
const frameIncrementPointerNames = {
|
||||
'00181065': 'frameTimeVector',
|
||||
'00181063': 'frameTime',
|
||||
};
|
||||
|
||||
if (!element || !element.Value || !element.Value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = element.Value[0];
|
||||
return frameIncrementPointerNames[value];
|
||||
}
|
||||
|
||||
function getRadiopharmaceuticalInfo(instance) {
|
||||
const modality = DICOMWeb.getString(instance['00080060']);
|
||||
|
||||
if (modality !== 'PT') {
|
||||
return;
|
||||
}
|
||||
|
||||
const radiopharmaceuticalInfo = instance['00540016'];
|
||||
if (
|
||||
radiopharmaceuticalInfo === undefined ||
|
||||
!radiopharmaceuticalInfo.Value ||
|
||||
!radiopharmaceuticalInfo.Value.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstPetRadiopharmaceuticalInfo = radiopharmaceuticalInfo.Value[0];
|
||||
return {
|
||||
radiopharmaceuticalStartTime: DICOMWeb.getString(
|
||||
firstPetRadiopharmaceuticalInfo['00181072']
|
||||
),
|
||||
radionuclideTotalDose: DICOMWeb.getNumber(
|
||||
firstPetRadiopharmaceuticalInfo['00181074']
|
||||
),
|
||||
radionuclideHalfLife: DICOMWeb.getNumber(
|
||||
firstPetRadiopharmaceuticalInfo['00181075']
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the SourceImageSequence, if it exists, in order
|
||||
* to return a ReferenceSOPInstanceUID. The ReferenceSOPInstanceUID
|
||||
* is used to refer to this image in any accompanying DICOM-SR documents.
|
||||
*
|
||||
* @param instance
|
||||
* @returns {String} The ReferenceSOPInstanceUID
|
||||
*/
|
||||
function getSourceImageInstanceUid(instance) {
|
||||
// TODO= Parse the whole Source Image Sequence
|
||||
// This is a really poor workaround for now.
|
||||
// Later we should probably parse the whole sequence.
|
||||
var SourceImageSequence = instance['00082112'];
|
||||
if (
|
||||
SourceImageSequence &&
|
||||
SourceImageSequence.Value &&
|
||||
SourceImageSequence.Value.length &&
|
||||
SourceImageSequence.Value[0]['00081155'].Value
|
||||
) {
|
||||
return SourceImageSequence.Value[0]['00081155'].Value[0];
|
||||
}
|
||||
}
|
||||
|
||||
async function makeSOPInstance(server, study, instance) {
|
||||
const { studyInstanceUid } = study;
|
||||
const seriesInstanceUid = DICOMWeb.getString(instance['0020000E']);
|
||||
let series = study.seriesMap[seriesInstanceUid];
|
||||
|
||||
if (!series) {
|
||||
series = {
|
||||
seriesInstanceUid,
|
||||
seriesDescription: DICOMWeb.getString(instance['0008103E']),
|
||||
modality: DICOMWeb.getString(instance['00080060']),
|
||||
seriesNumber: DICOMWeb.getNumber(instance['00200011']),
|
||||
seriesDate: DICOMWeb.getString(instance['00080021']),
|
||||
seriesTime: DICOMWeb.getString(instance['00080031']),
|
||||
instances: [],
|
||||
};
|
||||
study.seriesMap[seriesInstanceUid] = series;
|
||||
study.seriesList.push(series);
|
||||
}
|
||||
|
||||
const sopInstanceUid = DICOMWeb.getString(instance['00080018']);
|
||||
const wadouri = buildInstanceWadoUrl(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
const baseWadoRsUri = buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
const wadorsuri = buildInstanceFrameWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
|
||||
const sopInstance = {
|
||||
imageType: DICOMWeb.getString(instance['00080008']),
|
||||
sopClassUid: DICOMWeb.getString(instance['00080016']),
|
||||
modality: DICOMWeb.getString(instance['00080060']),
|
||||
sopInstanceUid,
|
||||
instanceNumber: DICOMWeb.getNumber(instance['00200013']),
|
||||
imagePositionPatient: DICOMWeb.getString(instance['00200032']),
|
||||
imageOrientationPatient: DICOMWeb.getString(instance['00200037']),
|
||||
frameOfReferenceUID: DICOMWeb.getString(instance['00200052']),
|
||||
sliceLocation: DICOMWeb.getNumber(instance['00201041']),
|
||||
samplesPerPixel: DICOMWeb.getNumber(instance['00280002']),
|
||||
photometricInterpretation: DICOMWeb.getString(instance['00280004']),
|
||||
planarConfiguration: DICOMWeb.getNumber(instance['00280006']),
|
||||
rows: DICOMWeb.getNumber(instance['00280010']),
|
||||
columns: DICOMWeb.getNumber(instance['00280011']),
|
||||
pixelSpacing: DICOMWeb.getString(instance['00280030']),
|
||||
pixelAspectRatio: DICOMWeb.getString(instance['00280034']),
|
||||
bitsAllocated: DICOMWeb.getNumber(instance['00280100']),
|
||||
bitsStored: DICOMWeb.getNumber(instance['00280101']),
|
||||
highBit: DICOMWeb.getNumber(instance['00280102']),
|
||||
pixelRepresentation: DICOMWeb.getNumber(instance['00280103']),
|
||||
smallestPixelValue: DICOMWeb.getNumber(instance['00280106']),
|
||||
largestPixelValue: DICOMWeb.getNumber(instance['00280107']),
|
||||
windowCenter: DICOMWeb.getString(instance['00281050']),
|
||||
windowWidth: DICOMWeb.getString(instance['00281051']),
|
||||
rescaleIntercept: DICOMWeb.getNumber(instance['00281052']),
|
||||
rescaleSlope: DICOMWeb.getNumber(instance['00281053']),
|
||||
rescaleType: DICOMWeb.getNumber(instance['00281054']),
|
||||
sourceImageInstanceUid: getSourceImageInstanceUid(instance),
|
||||
laterality: DICOMWeb.getString(instance['00200062']),
|
||||
viewPosition: DICOMWeb.getString(instance['00185101']),
|
||||
acquisitionDateTime: DICOMWeb.getString(instance['0008002A']),
|
||||
numberOfFrames: DICOMWeb.getNumber(instance['00280008']),
|
||||
frameIncrementPointer: getFrameIncrementPointer(instance['00280009']),
|
||||
frameTime: DICOMWeb.getNumber(instance['00181063']),
|
||||
frameTimeVector: parseFloatArray(DICOMWeb.getString(instance['00181065'])),
|
||||
sliceThickness: DICOMWeb.getNumber(instance['00180050']),
|
||||
spacingBetweenSlices: DICOMWeb.getString(instance['00180088']),
|
||||
lossyImageCompression: DICOMWeb.getString(instance['00282110']),
|
||||
derivationDescription: DICOMWeb.getString(instance['00282111']),
|
||||
lossyImageCompressionRatio: DICOMWeb.getString(instance['00282112']),
|
||||
lossyImageCompressionMethod: DICOMWeb.getString(instance['00282114']),
|
||||
echoNumber: DICOMWeb.getString(instance['00180086']),
|
||||
contrastBolusAgent: DICOMWeb.getString(instance['00180010']),
|
||||
radiopharmaceuticalInfo: getRadiopharmaceuticalInfo(instance),
|
||||
baseWadoRsUri: baseWadoRsUri,
|
||||
wadouri: WADOProxy.convertURL(wadouri, server),
|
||||
wadorsuri: WADOProxy.convertURL(wadorsuri, server),
|
||||
wadoRoot: server.wadoRoot,
|
||||
imageRendering: server.imageRendering,
|
||||
thumbnailRendering: server.thumbnailRendering,
|
||||
};
|
||||
|
||||
// Get additional information if the instance uses "PALETTE COLOR" photometric interpretation
|
||||
if (sopInstance.photometricInterpretation === 'PALETTE COLOR') {
|
||||
const redPaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281101'])
|
||||
);
|
||||
const greenPaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281102'])
|
||||
);
|
||||
const bluePaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281103'])
|
||||
);
|
||||
const palettes = await getPaletteColors(
|
||||
server,
|
||||
instance,
|
||||
redPaletteColorLookupTableDescriptor
|
||||
);
|
||||
|
||||
if (palettes) {
|
||||
if (palettes.uid) {
|
||||
sopInstance.paletteColorLookupTableUID = palettes.uid;
|
||||
}
|
||||
|
||||
sopInstance.redPaletteColorLookupTableData = palettes.red;
|
||||
sopInstance.greenPaletteColorLookupTableData = palettes.green;
|
||||
sopInstance.bluePaletteColorLookupTableData = palettes.blue;
|
||||
sopInstance.redPaletteColorLookupTableDescriptor = redPaletteColorLookupTableDescriptor;
|
||||
sopInstance.greenPaletteColorLookupTableDescriptor = greenPaletteColorLookupTableDescriptor;
|
||||
sopInstance.bluePaletteColorLookupTableDescriptor = bluePaletteColorLookupTableDescriptor;
|
||||
}
|
||||
}
|
||||
|
||||
series.instances.push(sopInstance);
|
||||
return sopInstance;
|
||||
}
|
||||
|
||||
function getPaletteColor(server, instance, tag, lutDescriptor) {
|
||||
const numLutEntries = lutDescriptor[0];
|
||||
const bits = lutDescriptor[2];
|
||||
|
||||
let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server);
|
||||
|
||||
// TODO: Workaround for dcm4chee behind SSL-terminating proxy returning
|
||||
// incorrect bulk data URIs
|
||||
if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) {
|
||||
uri = uri.replace('http', 'https');
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used
|
||||
headers: DICOMWeb.getAuthorizationHeader(server),
|
||||
};
|
||||
const dicomWeb = new api.DICOMwebClient(config);
|
||||
const options = {
|
||||
BulkDataURI: uri,
|
||||
};
|
||||
|
||||
const readUInt16 = (byteArray, position) => {
|
||||
return byteArray[position] + byteArray[position + 1] * 256;
|
||||
};
|
||||
|
||||
const arrayBufferToPaletteColorLUT = result => {
|
||||
const arraybuffer = result[0];
|
||||
const byteArray = new Uint8Array(arraybuffer);
|
||||
const lut = [];
|
||||
|
||||
for (let i = 0; i < numLutEntries; i++) {
|
||||
if (bits === 16) {
|
||||
lut[i] = readUInt16(byteArray, i * 2);
|
||||
} else {
|
||||
lut[i] = byteArray[i];
|
||||
}
|
||||
}
|
||||
|
||||
return lut;
|
||||
};
|
||||
|
||||
return dicomWeb.retrieveBulkData(options).then(arrayBufferToPaletteColorLUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch palette colors for instances with "PALETTE COLOR" photometricInterpretation.
|
||||
*
|
||||
* @param server {Object} Current server;
|
||||
* @param instance {Object} The retrieved instance metadata;
|
||||
* @returns {String} The ReferenceSOPInstanceUID
|
||||
*/
|
||||
async function getPaletteColors(server, instance, lutDescriptor) {
|
||||
let paletteUID = DICOMWeb.getString(instance['00281199']);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let entry;
|
||||
if (paletteColorCache.isValidUID(paletteUID)) {
|
||||
entry = paletteColorCache.get(paletteUID);
|
||||
|
||||
if (entry) {
|
||||
return resolve(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// no entry in cache... Fetch remote data.
|
||||
const r = getPaletteColor(server, instance, '00281201', lutDescriptor);
|
||||
const g = getPaletteColor(server, instance, '00281202', lutDescriptor);
|
||||
const b = getPaletteColor(server, instance, '00281203', lutDescriptor);
|
||||
|
||||
const promises = [r, g, b];
|
||||
|
||||
Promise.all(promises).then(args => {
|
||||
entry = {
|
||||
red: args[0],
|
||||
green: args[1],
|
||||
blue: args[2],
|
||||
};
|
||||
|
||||
// when paletteUID is present, the entry can be cached...
|
||||
entry.uid = paletteUID;
|
||||
paletteColorCache.add(entry);
|
||||
|
||||
resolve(entry);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a list of SOP Instances to a given study object descriptor
|
||||
* @param {Object} server Object with server configuration parameters
|
||||
* @param {Object} study The study descriptor to which the given SOP instances will be added
|
||||
* @param {Array} sopInstanceList A list of SOP instance objects
|
||||
*/
|
||||
async function addInstancesToStudy(server, study, sopInstanceList) {
|
||||
return Promise.all(
|
||||
sopInstanceList.map(function(sopInstance) {
|
||||
return makeSOPInstance(server, study, sopInstance);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const createStudyFromSOPInstanceList = async (server, sopInstanceList) => {
|
||||
if (Array.isArray(sopInstanceList) && sopInstanceList.length > 0) {
|
||||
const firstSopInstance = sopInstanceList[0];
|
||||
const study = createStudy(server, firstSopInstance);
|
||||
await addInstancesToStudy(server, study, sopInstanceList);
|
||||
return study;
|
||||
}
|
||||
throw new Error('Failed to create study out of provided SOP instance list');
|
||||
};
|
||||
|
||||
export { createStudyFromSOPInstanceList, addInstancesToStudy };
|
||||
@ -102,14 +102,14 @@ function ImageThumbnail(props) {
|
||||
{shouldRenderToCanvas() ? (
|
||||
<canvas ref={canvasRef} width={width} height={height} />
|
||||
) : (
|
||||
<img
|
||||
className="static-image"
|
||||
src={imageSrc}
|
||||
//width={this.props.width}
|
||||
height={height}
|
||||
alt={''}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
className="static-image"
|
||||
src={imageSrc}
|
||||
//width={this.props.width}
|
||||
height={height}
|
||||
alt={''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loadingOrError}
|
||||
{showStackLoadingProgressBar && (
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
//We are keeping the hardcoded results values for the study list tests
|
||||
//this is intended to be running in a controled docker environment with test data.
|
||||
describe('OHIF Study List', function() {
|
||||
context('Desktop resolution', function() {
|
||||
beforeEach(function() {
|
||||
describe('OHIF Study List', function () {
|
||||
context('Desktop resolution', function () {
|
||||
beforeEach(function () {
|
||||
cy.viewport(1750, 720);
|
||||
cy.openStudyList();
|
||||
cy.initStudyListAliasesOnDesktop();
|
||||
});
|
||||
|
||||
it('searches Patient Name with exact string', function() {
|
||||
it('searches Patient Name with exact string', function () {
|
||||
cy.get('@patientName').type('Juno');
|
||||
//Wait result list to be displayed
|
||||
cy.waitStudyList();
|
||||
@ -18,7 +18,7 @@ describe('OHIF Study List', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('searches MRN with exact string', function() {
|
||||
it('searches MRN with exact string', function () {
|
||||
cy.get('@MRN').type('ProstateX-0000');
|
||||
//Wait result list to be displayed
|
||||
cy.waitStudyList();
|
||||
@ -28,7 +28,7 @@ describe('OHIF Study List', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('searches Accession with exact string', function() {
|
||||
it('searches Accession with exact string', function () {
|
||||
cy.get('@accessionNumber').type('fpcben98890');
|
||||
//Wait result list to be displayed
|
||||
cy.waitStudyList();
|
||||
@ -38,7 +38,7 @@ describe('OHIF Study List', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('searches Modality with camel case', function() {
|
||||
it('searches Modality with camel case', function () {
|
||||
cy.get('@modalities').type('Mr');
|
||||
//Wait result list to be displayed
|
||||
cy.waitStudyList();
|
||||
@ -48,7 +48,7 @@ describe('OHIF Study List', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('searches Description with exact string', function() {
|
||||
it('searches Description with exact string', function () {
|
||||
cy.get('@studyDescription').type('CHEST');
|
||||
//Wait result list to be displayed
|
||||
cy.waitStudyList();
|
||||
@ -58,7 +58,7 @@ describe('OHIF Study List', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('changes rows per page and checks the study count', function() {
|
||||
it('changes rows per page and checks the study count', function () {
|
||||
//Show rows per page options
|
||||
const pageRows = [25, 50, 100];
|
||||
|
||||
@ -100,14 +100,14 @@ describe('OHIF Study List', function() {
|
||||
// });
|
||||
});
|
||||
|
||||
context('Tablet resolution', function() {
|
||||
beforeEach(function() {
|
||||
context('Tablet resolution', function () {
|
||||
beforeEach(function () {
|
||||
cy.viewport(1000, 660);
|
||||
cy.openStudyList();
|
||||
cy.initStudyListAliasesOnTablet();
|
||||
});
|
||||
|
||||
it('searches Patient Name with exact string', function() {
|
||||
it('searches Patient Name with exact string', function () {
|
||||
cy.get('@patientNameOrMRN').type('Juno');
|
||||
//Wait result list to be displayed
|
||||
cy.waitStudyList();
|
||||
@ -117,7 +117,7 @@ describe('OHIF Study List', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('searches MRN with with exact string', function() {
|
||||
it('searches MRN with with exact string', function () {
|
||||
cy.get('@patientNameOrMRN').type('ProstateX-0000');
|
||||
//Wait result list to be displayed
|
||||
cy.waitStudyList();
|
||||
@ -127,7 +127,7 @@ describe('OHIF Study List', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('searches Modality with exact string', function() {
|
||||
it('searches Modality with exact string', function () {
|
||||
cy.get('@accessionModalityDescription').type('MR');
|
||||
//Wait result list to be displayed
|
||||
cy.waitStudyList();
|
||||
@ -137,7 +137,7 @@ describe('OHIF Study List', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('searches Accession with exact string', function() {
|
||||
it('searches Accession with exact string', function () {
|
||||
cy.get('@accessionModalityDescription').type('fpcben98890');
|
||||
//Wait result list to be displayed
|
||||
cy.waitStudyList();
|
||||
@ -147,7 +147,7 @@ describe('OHIF Study List', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('searches Description with exact string', function() {
|
||||
it('searches Description with exact string', function () {
|
||||
cy.get('@accessionModalityDescription').type('CHEST');
|
||||
//Wait result list to be displayed
|
||||
cy.waitStudyList();
|
||||
@ -157,7 +157,7 @@ describe('OHIF Study List', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('changes rows per page and checks the study count', function() {
|
||||
it('changes rows per page and checks the study count', function () {
|
||||
//Show rows per page options
|
||||
const pageRows = [25, 50, 100];
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ window.config = {
|
||||
routerBasename: '/',
|
||||
extensions: [],
|
||||
showStudyList: true,
|
||||
filterQueryParam: false,
|
||||
servers: {
|
||||
dicomWeb: [
|
||||
{
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { connect } from 'react-redux';
|
||||
import ViewerRetrieveStudyData from './ViewerRetrieveStudyData.js';
|
||||
import OHIF from "@ohif/core";
|
||||
|
||||
const {
|
||||
clearViewportSpecificData
|
||||
} = OHIF.redux.actions;
|
||||
const isActive = a => a.active === true;
|
||||
|
||||
const mapStateToProps = state => {
|
||||
@ -10,10 +14,17 @@ const mapStateToProps = state => {
|
||||
server: activeServer,
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
clearViewportSpecificData: () => {
|
||||
dispatch(clearViewportSpecificData());
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const ConnectedViewerRetrieveStudyData = connect(
|
||||
mapStateToProps,
|
||||
null
|
||||
mapDispatchToProps
|
||||
)(ViewerRetrieveStudyData);
|
||||
|
||||
export default ConnectedViewerRetrieveStudyData;
|
||||
|
||||
@ -1,191 +1,346 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { metadata, studies, utils, log } from '@ohif/core';
|
||||
|
||||
import ConnectedViewer from './ConnectedViewer.js';
|
||||
import PropTypes from 'prop-types';
|
||||
import { extensionManager } from './../App.js';
|
||||
import { withSnackbar } from '@ohif/ui';
|
||||
import { useSnackbarContext } from '@ohif/ui';
|
||||
|
||||
const { OHIFStudyMetadata, OHIFSeriesMetadata } = metadata;
|
||||
const { retrieveStudiesMetadata, deleteStudyMetadataPromise } = studies;
|
||||
const { studyMetadataManager, updateMetaDataManager } = utils;
|
||||
const { studyMetadataManager, updateMetaDataManager, makeCancelable } = utils;
|
||||
|
||||
class ViewerRetrieveStudyData extends Component {
|
||||
static propTypes = {
|
||||
studyInstanceUids: PropTypes.array.isRequired,
|
||||
seriesInstanceUids: PropTypes.array,
|
||||
server: PropTypes.object,
|
||||
// Contexts
|
||||
import AppContext from '../context/AppContext';
|
||||
|
||||
const _promoteToFront = (list, value, searchMethod) => {
|
||||
let response = [...list];
|
||||
let promoted = false;
|
||||
const index = response.findIndex(searchMethod.bind(undefined, value));
|
||||
|
||||
if (index > 0) {
|
||||
const first = response.splice(index, 1);
|
||||
response = [...first, ...response];
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
promoted = true;
|
||||
}
|
||||
|
||||
return {
|
||||
promoted,
|
||||
data: response,
|
||||
};
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.abortSeriesLoad = false;
|
||||
this.seriesLoadStats = Object.create(null);
|
||||
this.state = {
|
||||
studies: null,
|
||||
error: null,
|
||||
};
|
||||
/**
|
||||
* Promote series to front if find found equivalent on filters object
|
||||
* @param {Object} study - study reference to promote series against
|
||||
* @param {Object} [filters] - Object containing filters to be applied
|
||||
* @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against
|
||||
* @param {boolean} isFilterStrategy - if filtering by query param strategy ON
|
||||
*/
|
||||
const _promoteList = (study, studyMetadata, filters, isFilterStrategy) => {
|
||||
let promoted = false;
|
||||
// Promote only if no filter should be applied
|
||||
if (!isFilterStrategy) {
|
||||
_sortStudyDisplaySet(study, studyMetadata);
|
||||
promoted = _promoteStudyDisplaySet(study, studyMetadata, filters);
|
||||
}
|
||||
|
||||
async loadStudies() {
|
||||
try {
|
||||
const { server, studyInstanceUids, seriesInstanceUids } = this.props;
|
||||
const filters = {};
|
||||
return promoted;
|
||||
};
|
||||
|
||||
// Use the first, discard others
|
||||
const seriesInstanceUID = seriesInstanceUids && seriesInstanceUids[0];
|
||||
const _promoteStudyDisplaySet = (study, studyMetadata, filters) => {
|
||||
let promoted = false;
|
||||
const queryParamsLength = Object.keys(filters).length;
|
||||
const shouldPromoteToFront = queryParamsLength > 0;
|
||||
|
||||
if (seriesInstanceUID) {
|
||||
filters.seriesInstanceUID = seriesInstanceUID;
|
||||
}
|
||||
|
||||
const studies = await retrieveStudiesMetadata(
|
||||
server,
|
||||
studyInstanceUids,
|
||||
filters
|
||||
);
|
||||
this.validateFilters(studies, filters);
|
||||
this.setStudies(studies);
|
||||
} catch (e) {
|
||||
this.setState({ error: true });
|
||||
log.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate filters and promp user a message in case filter is unsuccessfully applied.
|
||||
* In case of success, studies array contains, as the first element, the queried content (from filter)
|
||||
* @param {Array} studies array of studies to be evaluated
|
||||
* @param {Object} filters filters to test against
|
||||
*/
|
||||
validateFilters(studies = [], filters = {}) {
|
||||
if (shouldPromoteToFront) {
|
||||
const { seriesInstanceUID } = filters;
|
||||
|
||||
const { snackbarContext } = this.props;
|
||||
// skip in case no filter or no toast manager
|
||||
if (!seriesInstanceUID || !snackbarContext) {
|
||||
return;
|
||||
}
|
||||
const _seriesLookup = (valueToCompare, displaySet) => {
|
||||
return displaySet.seriesInstanceUid === valueToCompare;
|
||||
};
|
||||
const promotedResponse = _promoteToFront(
|
||||
studyMetadata.getDisplaySets(),
|
||||
seriesInstanceUID,
|
||||
_seriesLookup
|
||||
);
|
||||
|
||||
const firstStudy = studies[0] || {};
|
||||
const { seriesList = [] } = firstStudy;
|
||||
const firstSeries = seriesList[0];
|
||||
|
||||
if (!firstSeries || firstSeries.seriesInstanceUid !== seriesInstanceUID) {
|
||||
snackbarContext.show({
|
||||
message: 'No series for given filter: ' + seriesInstanceUID,
|
||||
});
|
||||
}
|
||||
study.displaySets = promotedResponse.data;
|
||||
promoted = promotedResponse.promoted;
|
||||
}
|
||||
|
||||
setStudies(givenStudies) {
|
||||
if (Array.isArray(givenStudies) && givenStudies.length > 0) {
|
||||
const sopClassHandlerModules =
|
||||
extensionManager.modules['sopClassHandlerModule'];
|
||||
return promoted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to identify if query param (from url) was applied to given list
|
||||
* @param {Object} study - study reference to promote series against
|
||||
* @param {Object} [filters] - Object containing filters to be applied
|
||||
* @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against
|
||||
* @param {boolean} isFilterStrategy - if filtering by query param strategy ON
|
||||
*/
|
||||
const _isQueryParamApplied = (study, filters = {}, isFilterStrategy) => {
|
||||
const { seriesInstanceUID } = filters;
|
||||
let applied = true;
|
||||
// skip in case no filter or no toast manager
|
||||
if (!seriesInstanceUID) {
|
||||
return applied;
|
||||
}
|
||||
|
||||
const { seriesList = [], displaySets = [] } = study;
|
||||
const firstSeries = isFilterStrategy ? seriesList[0] : displaySets[0];
|
||||
|
||||
if (!firstSeries || firstSeries.seriesInstanceUid !== seriesInstanceUID) {
|
||||
applied = false;
|
||||
}
|
||||
|
||||
return applied;
|
||||
};
|
||||
const _showUserMessage = (queryParamApplied, message, dialog = {}) => {
|
||||
if (queryParamApplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { show: showUserMessage = () => { } } = dialog;
|
||||
showUserMessage({
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
const _addSeriesToStudy = (studyMetadata, series) => {
|
||||
const sopClassHandlerModules =
|
||||
extensionManager.modules['sopClassHandlerModule'];
|
||||
const study = studyMetadata.getData();
|
||||
const seriesMetadata = new OHIFSeriesMetadata(series, study);
|
||||
studyMetadata.addSeries(seriesMetadata);
|
||||
studyMetadata.createAndAddDisplaySetsForSeries(
|
||||
sopClassHandlerModules,
|
||||
seriesMetadata,
|
||||
false
|
||||
);
|
||||
study.displaySets = studyMetadata.getDisplaySets();
|
||||
_updateMetaDataManager(study, series.seriesInstanceUid);
|
||||
};
|
||||
|
||||
const _updateMetaDataManager = (study, studyMetadata, series) => {
|
||||
updateMetaDataManager(study, series);
|
||||
|
||||
const { studyInstanceUID } = study;
|
||||
|
||||
if (!studyMetadataManager.get(studyInstanceUID)) {
|
||||
studyMetadataManager.add(studyMetadata);
|
||||
}
|
||||
};
|
||||
|
||||
const _updateStudyDisplaySets = (study, studyMetadata) => {
|
||||
const sopClassHandlerModules =
|
||||
extensionManager.modules['sopClassHandlerModule'];
|
||||
|
||||
if (!study.displaySets) {
|
||||
study.displaySets = studyMetadata.createDisplaySets(sopClassHandlerModules);
|
||||
}
|
||||
|
||||
studyMetadata.setDisplaySets(study.displaySets);
|
||||
};
|
||||
|
||||
const _sortStudyDisplaySet = (study, studyMetadata) => {
|
||||
studyMetadata.sortDisplaySets(study.displaySets);
|
||||
};
|
||||
const _loadRemainingSeries = studyMetadata => {
|
||||
const { seriesLoader } = studyMetadata.getData();
|
||||
if (!seriesLoader) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const promisesLoaders = [];
|
||||
while (seriesLoader.hasNext()) {
|
||||
promisesLoaders.push(
|
||||
seriesLoader
|
||||
.next()
|
||||
.then(
|
||||
series => void _addSeriesToStudy(studyMetadata, series),
|
||||
error => void log.error(error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promisesLoaders);
|
||||
};
|
||||
|
||||
function ViewerRetrieveStudyData({
|
||||
server,
|
||||
studyInstanceUids,
|
||||
seriesInstanceUids,
|
||||
clearViewportSpecificData,
|
||||
}) {
|
||||
// hooks
|
||||
const [error, setError] = useState(false);
|
||||
const [studies, setStudies] = useState([]);
|
||||
const snackbarContext = useSnackbarContext();
|
||||
const { appConfig = {} } = useContext(AppContext);
|
||||
const { filterQueryParam: isFilterStrategy = false } = appConfig;
|
||||
|
||||
let cancelableSeriesPromises;
|
||||
let cancelableStudiesPromises;
|
||||
/**
|
||||
* Callback method when study is totally loaded
|
||||
* @param {object} study study loaded
|
||||
* @param {object} studyMetadata studyMetadata for given study
|
||||
* @param {Object} [filters] - Object containing filters to be applied
|
||||
* @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against
|
||||
*/
|
||||
const studyDidLoad = (study, studyMetadata, filters) => {
|
||||
// User message
|
||||
const promoted = _promoteList(
|
||||
study,
|
||||
studyMetadata,
|
||||
filters,
|
||||
isFilterStrategy
|
||||
);
|
||||
|
||||
// Clear viewport to allow new promoted one to be displayed
|
||||
if (promoted) {
|
||||
clearViewportSpecificData(0);
|
||||
}
|
||||
|
||||
const isQueryParamApplied = _isQueryParamApplied(
|
||||
study,
|
||||
filters,
|
||||
isFilterStrategy
|
||||
);
|
||||
// Show message in case not promoted neither filtered but should to
|
||||
_showUserMessage(
|
||||
isQueryParamApplied,
|
||||
'Query parameters were not applied. Using original series list for given study.',
|
||||
snackbarContext
|
||||
);
|
||||
|
||||
setStudies([...studies, study]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to process studies. It will update displaySet, studyMetadata, load remaining series, ...
|
||||
* @param {Array} studiesData Array of studies retrieved from server
|
||||
* @param {Object} [filters] - Object containing filters to be applied
|
||||
* @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against
|
||||
*/
|
||||
const processStudies = (studiesData, filters) => {
|
||||
if (Array.isArray(studiesData) && studiesData.length > 0) {
|
||||
// Map studies to new format, update metadata manager?
|
||||
const studies = givenStudies.map(study => {
|
||||
const studies = studiesData.map(study => {
|
||||
const studyMetadata = new OHIFStudyMetadata(
|
||||
study,
|
||||
study.studyInstanceUid
|
||||
);
|
||||
if (!study.displaySets) {
|
||||
study.displaySets = studyMetadata.createDisplaySets(
|
||||
sopClassHandlerModules
|
||||
);
|
||||
}
|
||||
studyMetadata.setDisplaySets(study.displaySets);
|
||||
// Updates WADO-RS metaDataManager
|
||||
updateMetaDataManager(study);
|
||||
studyMetadataManager.add(studyMetadata);
|
||||
|
||||
_updateStudyDisplaySets(study, studyMetadata);
|
||||
_updateMetaDataManager(study, studyMetadata);
|
||||
|
||||
// Attempt to load remaning series if any
|
||||
this._attemptToLoadRemainingSeries(studyMetadata);
|
||||
cancelableSeriesPromises[study.studyInstanceUid] = makeCancelable(
|
||||
_loadRemainingSeries(studyMetadata)
|
||||
)
|
||||
.then(result => {
|
||||
if (result && !result.isCanceled) {
|
||||
studyDidLoad(study, studyMetadata, filters);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (error && !error.isCanceled) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
|
||||
return study;
|
||||
});
|
||||
this.setState({ studies });
|
||||
}
|
||||
}
|
||||
|
||||
_addSeriesToStudy(studyMetadata, series) {
|
||||
const sopClassHandlerModules =
|
||||
extensionManager.modules['sopClassHandlerModule'];
|
||||
const study = studyMetadata.getData();
|
||||
const seriesMetadata = new OHIFSeriesMetadata(series, study);
|
||||
studyMetadata.addSeries(seriesMetadata);
|
||||
studyMetadata.createAndAddDisplaySetsForSeries(
|
||||
sopClassHandlerModules,
|
||||
seriesMetadata
|
||||
);
|
||||
study.displaySets = studyMetadata.getDisplaySets();
|
||||
updateMetaDataManager(study, series.seriesInstanceUid);
|
||||
this.setState(function(state) {
|
||||
return { studies: state.studies.slice() };
|
||||
});
|
||||
}
|
||||
|
||||
_handleSeriesLoadResult(error, studyMetadata, series) {
|
||||
if (this.abortSeriesLoad) return;
|
||||
const stats = this.seriesLoadStats[studyMetadata.getStudyInstanceUID()];
|
||||
if (!stats) return;
|
||||
stats.count--;
|
||||
if (error || !series) {
|
||||
stats.errors++;
|
||||
log.error(error || 'Bad Series');
|
||||
return;
|
||||
setStudies(studies);
|
||||
}
|
||||
this._addSeriesToStudy(studyMetadata, series);
|
||||
}
|
||||
};
|
||||
|
||||
_attemptToLoadRemainingSeries(studyMetadata) {
|
||||
const { seriesLoader } = studyMetadata.getData();
|
||||
if (!seriesLoader) {
|
||||
return;
|
||||
}
|
||||
const stats = (this.seriesLoadStats[studyMetadata.getStudyInstanceUID()] = {
|
||||
errors: 0,
|
||||
count: 0,
|
||||
});
|
||||
while (seriesLoader.hasNext()) {
|
||||
seriesLoader
|
||||
.next()
|
||||
.then(
|
||||
series =>
|
||||
void this._handleSeriesLoadResult(null, studyMetadata, series),
|
||||
error => void this._handleSeriesLoadResult({ error }, null, null)
|
||||
);
|
||||
stats.count++;
|
||||
}
|
||||
}
|
||||
const loadStudies = async () => {
|
||||
try {
|
||||
const filters = {};
|
||||
// Use the first, discard others
|
||||
const seriesInstanceUID = seriesInstanceUids && seriesInstanceUids[0];
|
||||
|
||||
componentWillUnmount() {
|
||||
this.abortSeriesLoad = true;
|
||||
for (const studyInstanceUid in this.seriesLoadStats) {
|
||||
const stats = this.seriesLoadStats[studyInstanceUid];
|
||||
if (stats && (stats.count > 0 || stats.errors > 0)) {
|
||||
deleteStudyMetadataPromise(studyInstanceUid);
|
||||
studyMetadataManager.remove(studyInstanceUid);
|
||||
log.info(`Purging incomplete study data: ${studyInstanceUid}`);
|
||||
const retrieveParams = [server, studyInstanceUids];
|
||||
|
||||
if (seriesInstanceUID) {
|
||||
filters.seriesInstanceUID = seriesInstanceUID;
|
||||
// Query param filtering controlled by appConfig property
|
||||
if (isFilterStrategy) {
|
||||
retrieveParams.push(filters);
|
||||
}
|
||||
}
|
||||
|
||||
cancelableStudiesPromises[studyInstanceUids] = makeCancelable(
|
||||
retrieveStudiesMetadata(...retrieveParams)
|
||||
)
|
||||
.then(result => {
|
||||
if (result && !result.isCanceled) {
|
||||
processStudies(result, filters);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (error && !error.isCanceled) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error) {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// TODO: CLEAR THIS SOMEWHERE ELSE
|
||||
studyMetadataManager.purge();
|
||||
this.loadStudies();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <div>Error: {JSON.stringify(this.state.error)}</div>;
|
||||
const purgeCancellablePromises = () => {
|
||||
for (let studyInstanceUids in cancelableStudiesPromises) {
|
||||
if ('cancel' in cancelableStudiesPromises[studyInstanceUids]) {
|
||||
cancelableStudiesPromises[studyInstanceUids].cancel();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConnectedViewer
|
||||
studies={this.state.studies}
|
||||
studyInstanceUids={this.props.studyInstanceUids}
|
||||
/>
|
||||
);
|
||||
for (let studyInstanceUids in cancelableSeriesPromises) {
|
||||
if ('cancel' in cancelableSeriesPromises[studyInstanceUids]) {
|
||||
cancelableSeriesPromises[studyInstanceUids].cancel();
|
||||
deleteStudyMetadataPromise(studyInstanceUids);
|
||||
studyMetadataManager.remove(studyInstanceUids);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
studyMetadataManager.purge();
|
||||
purgeCancellablePromises();
|
||||
}, [studyInstanceUids]);
|
||||
|
||||
useEffect(() => {
|
||||
cancelableSeriesPromises = {};
|
||||
cancelableStudiesPromises = {};
|
||||
loadStudies();
|
||||
|
||||
return () => {
|
||||
purgeCancellablePromises();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {JSON.stringify(error)}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConnectedViewer studies={studies} studyInstanceUids={studyInstanceUids} />
|
||||
);
|
||||
}
|
||||
|
||||
export default withSnackbar(ViewerRetrieveStudyData);
|
||||
ViewerRetrieveStudyData.propTypes = {
|
||||
studyInstanceUids: PropTypes.array.isRequired,
|
||||
seriesInstanceUids: PropTypes.array,
|
||||
server: PropTypes.object,
|
||||
clearViewportSpecificData: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ViewerRetrieveStudyData;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user