[OHIF-309] - Users should be able to delete an SR from the side panel when the behavior is supported by the data source (#1952)

* Remove trailing zeroes from patient age

* WIP reject + remove displaySet.

* ohif-316: Update DICOM SR thumbnail design to include a delete action (#1948)

* add "supportsReject" to netlify config

Co-authored-by: Igor Octaviano <igoroctaviano@gmail.com>
Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
This commit is contained in:
James Petts 2020-08-19 20:27:34 +01:00 committed by GitHub
parent 760e2a62ae
commit 68332701ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 236 additions and 41 deletions

View File

@ -236,9 +236,8 @@ export default function init({
id: dialogId, id: dialogId,
centralize: true, centralize: true,
isDraggable: false, isDraggable: false,
content: Dialog,
useLastPosition: false,
showOverlay: true, showOverlay: true,
content: Dialog,
contentProps: { contentProps: {
title: 'Enter your annotation', title: 'Enter your annotation',
value: { label }, value: { label },

View File

@ -0,0 +1,35 @@
export default function (wadoRoot) {
return {
series: (StudyInstanceUID, SeriesInstanceUID) => {
return new Promise((resolve, reject) => {
// Reject because of Quality. (Seems the most sensible out of the options)
const CodeValueAndCodeSchemeDesignator = `113001%5EDCM`;
const url = `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/reject/${CodeValueAndCodeSchemeDesignator}`;
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
//Send the proper header information along with the request
// TODO -> Auth when we re-add authorization.
console.log(xhr);
xhr.onreadystatechange = function () {
//Call a function when the state changes.
if (xhr.readyState == 4) {
switch (xhr.status) {
case 204:
resolve(xhr.responseText);
break;
case 404:
reject('Your dataSource does not support reject functionality');
}
}
};
xhr.send();
});
},
};
}

View File

@ -6,6 +6,7 @@ import {
processResults, processResults,
processSeriesResults, processSeriesResults,
} from './qido.js'; } from './qido.js';
import dcm4cheeReject from './dcm4cheeReject';
import { DicomMetadataStore, IWebApiDataSource, utils } from '@ohif/core'; import { DicomMetadataStore, IWebApiDataSource, utils } from '@ohif/core';
import getImageId from './utils/getImageId'; import getImageId from './utils/getImageId';
@ -34,6 +35,7 @@ const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1';
* @param {boolean} qidoSupportsIncludeField - Whether QIDO supports the "Include" option to request additional fields in response * @param {boolean} qidoSupportsIncludeField - Whether QIDO supports the "Include" option to request additional fields in response
* @param {string} imageRengering - wadors | ? (unsure of where/how this is used) * @param {string} imageRengering - wadors | ? (unsure of where/how this is used)
* @param {string} thumbnailRendering - wadors | ? (unsure of where/how this is used) * @param {string} thumbnailRendering - wadors | ? (unsure of where/how this is used)
* @param {bool} supportsReject - Whether the server supports reject calls (i.e. DCM4CHEE)
* @param {bool} lazyLoadStudy - "enableStudyLazyLoad"; Request series meta async instead of blocking * @param {bool} lazyLoadStudy - "enableStudyLazyLoad"; Request series meta async instead of blocking
*/ */
function createDicomWebApi(dicomWebConfig) { function createDicomWebApi(dicomWebConfig) {
@ -43,6 +45,7 @@ function createDicomWebApi(dicomWebConfig) {
enableStudyLazyLoad, enableStudyLazyLoad,
supportsFuzzyMatching, supportsFuzzyMatching,
supportsWildcard, supportsWildcard,
supportsReject,
} = dicomWebConfig; } = dicomWebConfig;
const qidoConfig = { const qidoConfig = {
@ -59,7 +62,7 @@ function createDicomWebApi(dicomWebConfig) {
const qidoDicomWebClient = new api.DICOMwebClient(qidoConfig); const qidoDicomWebClient = new api.DICOMwebClient(qidoConfig);
const wadoDicomWebClient = new api.DICOMwebClient(wadoConfig); const wadoDicomWebClient = new api.DICOMwebClient(wadoConfig);
return IWebApiDataSource.create({ const implementation = {
query: { query: {
studies: { studies: {
mapParams: mapParams.bind(), mapParams: mapParams.bind(),
@ -234,7 +237,13 @@ function createDicomWebApi(dicomWebConfig) {
return imageIds; return imageIds;
}, },
}); };
if (supportsReject) {
implementation.reject = dcm4cheeReject(wadoRoot);
}
return IWebApiDataSource.create(implementation);
} }
export { createDicomWebApi }; export { createDicomWebApi };

View File

@ -43,6 +43,23 @@ function OHIFCornerstoneSRViewport({
const [isHydrated, setIsHydrated] = useState(displaySet.isHydrated); const [isHydrated, setIsHydrated] = useState(displaySet.isHydrated);
const { viewports, activeViewportIndex } = viewportGrid; const { viewports, activeViewportIndex } = viewportGrid;
useEffect(() => {
const onDisplaySetsRemovedSubscription = DisplaySetService.subscribe(
DisplaySetService.EVENTS.DISPLAY_SETS_REMOVED, ({ displaySetInstanceUIDs }) => {
const activeViewport = viewports[activeViewportIndex];
if (displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)) {
viewportGridService.setDisplaysetForViewport({
viewportIndex: activeViewportIndex,
displaySetInstanceUID: undefined,
});
}
});
return () => {
onDisplaySetsRemovedSubscription.unsubscribe();
};
}, []);
// Optional hook into tracking extension, if present. // Optional hook into tracking extension, if present.
let trackedMeasurements; let trackedMeasurements;
let sendTrackedMeasurementsEvent; let sendTrackedMeasurementsEvent;

View File

@ -191,7 +191,6 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
id: 'enter-annotation', id: 'enter-annotation',
centralize: true, centralize: true,
isDraggable: false, isDraggable: false,
useLastPosition: false,
showOverlay: true, showOverlay: true,
content: Dialog, content: Dialog,
contentProps: { contentProps: {

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { utils } from '@ohif/core'; import { utils } from '@ohif/core';
import { StudyBrowser, useImageViewer, useViewportGrid } from '@ohif/ui'; import { StudyBrowser, useImageViewer, useViewportGrid, Dialog } from '@ohif/ui';
import { useTrackedMeasurements } from '../../getContextModule'; import { useTrackedMeasurements } from '../../getContextModule';
const { formatDate } = utils; const { formatDate } = utils;
@ -13,6 +13,8 @@ const { formatDate } = utils;
function PanelStudyBrowserTracking({ function PanelStudyBrowserTracking({
MeasurementService, MeasurementService,
DisplaySetService, DisplaySetService,
UIDialogService,
UINotificationService,
getImageSrc, getImageSrc,
getStudiesForPatientByStudyInstanceUID, getStudiesForPatientByStudyInstanceUID,
requestDisplaySetCreationForStudy, requestDisplaySetCreationForStudy,
@ -136,7 +138,11 @@ function PanelStudyBrowserTracking({
thumbnailImageSrcMap, thumbnailImageSrcMap,
trackedSeries, trackedSeries,
viewports, viewports,
isSingleViewport isSingleViewport,
dataSource,
DisplaySetService,
UIDialogService,
UINotificationService
); );
setDisplaySets(mappedDisplaySets); setDisplaySets(mappedDisplaySets);
@ -146,6 +152,7 @@ function PanelStudyBrowserTracking({
trackedSeries, trackedSeries,
thumbnailImageSrcMap, thumbnailImageSrcMap,
viewports, viewports,
dataSource,
]); ]);
// ~~ subscriptions --> displaySets // ~~ subscriptions --> displaySets
@ -193,7 +200,11 @@ function PanelStudyBrowserTracking({
thumbnailImageSrcMap, thumbnailImageSrcMap,
trackedSeries, trackedSeries,
viewports, viewports,
isSingleViewport isSingleViewport,
dataSource,
DisplaySetService,
UIDialogService,
UINotificationService
); );
setDisplaySets(mappedDisplaySets); setDisplaySets(mappedDisplaySets);
@ -227,10 +238,10 @@ function PanelStudyBrowserTracking({
); );
const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy
? [ ? [
...expandedStudyInstanceUIDs.filter( ...expandedStudyInstanceUIDs.filter(
stdyUid => stdyUid !== StudyInstanceUID stdyUid => stdyUid !== StudyInstanceUID
), ),
] ]
: [...expandedStudyInstanceUIDs, StudyInstanceUID]; : [...expandedStudyInstanceUIDs, StudyInstanceUID];
setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs); setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs);
@ -304,7 +315,7 @@ function PanelStudyBrowserTracking({
SeriesInstanceUID: displaySet.SeriesInstanceUID, SeriesInstanceUID: displaySet.SeriesInstanceUID,
}); });
}} }}
onClickThumbnail={() => {}} onClickThumbnail={() => { }}
onDoubleClickThumbnail={onDoubleClickThumbnailHandler} onDoubleClickThumbnail={onDoubleClickThumbnailHandler}
activeDisplaySetInstanceUID={activeDisplaySetInstanceUID} activeDisplaySetInstanceUID={activeDisplaySetInstanceUID}
/> />
@ -359,8 +370,13 @@ function _mapDisplaySets(
thumbnailImageSrcMap, thumbnailImageSrcMap,
trackedSeriesInstanceUIDs, trackedSeriesInstanceUIDs,
viewports, // TODO: make array of `displaySetInstanceUIDs`? viewports, // TODO: make array of `displaySetInstanceUIDs`?
isSingleViewport isSingleViewport,
dataSource,
DisplaySetService,
UIDialogService,
UINotificationService
) { ) {
console.log(displaySets.length);
const thumbnailDisplaySets = []; const thumbnailDisplaySets = [];
const thumbnailNoImageDisplaySets = []; const thumbnailNoImageDisplaySets = [];
displaySets.forEach(ds => { displaySets.forEach(ds => {
@ -369,19 +385,21 @@ function _mapDisplaySets(
const viewportIdentificator = isSingleViewport const viewportIdentificator = isSingleViewport
? [] ? []
: viewports.reduce((acc, viewportData, index) => { : viewports.reduce((acc, viewportData, index) => {
if (viewportData.displaySetInstanceUID === ds.displaySetInstanceUID) { if (viewportData.displaySetInstanceUID === ds.displaySetInstanceUID) {
acc.push(_viewportLabels[index]); acc.push(_viewportLabels[index]);
} }
return acc; return acc;
}, []); }, []);
const array = const array =
componentType === 'thumbnailTracked' componentType === 'thumbnailTracked'
? thumbnailDisplaySets ? thumbnailDisplaySets
: thumbnailNoImageDisplaySets; : thumbnailNoImageDisplaySets;
array.push({ const { displaySetInstanceUID } = ds;
displaySetInstanceUID: ds.displaySetInstanceUID,
const thumbnailProps = {
displaySetInstanceUID,
description: ds.SeriesDescription, description: ds.SeriesDescription,
seriesNumber: ds.SeriesNumber, seriesNumber: ds.SeriesNumber,
modality: ds.Modality, modality: ds.Modality,
@ -392,12 +410,71 @@ function _mapDisplaySets(
imageSrc, imageSrc,
dragData: { dragData: {
type: 'displayset', type: 'displayset',
displaySetInstanceUID: ds.displaySetInstanceUID, displaySetInstanceUID,
// .. Any other data to pass // .. Any other data to pass
}, },
isTracked: trackedSeriesInstanceUIDs.includes(ds.SeriesInstanceUID), isTracked: trackedSeriesInstanceUIDs.includes(ds.SeriesInstanceUID),
viewportIdentificator, viewportIdentificator,
}); };
if (componentType === 'thumbnailNoImage') {
if (dataSource.reject && dataSource.reject.series) {
thumbnailProps.canReject = true;
thumbnailProps.onReject = () => {
UIDialogService.create({
id: 'ds-reject-sr',
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Reject Report',
body: () => (
<div className="p-4 bg-primary-dark text-white">
<p>This is a destructive action.</p>
<p>Are you sure you want to continue?</p>
</div>
),
actions: [
{ id: 'cancel', text: 'Cancel', type: 'secondary' },
{ id: 'save', text: 'Save', type: 'primary' },
],
onClose: () => UIDialogService.dismiss({ id: 'ds-reject-sr' }),
onSubmit: async ({ action }) => {
switch (action.id) {
case 'save':
try {
await dataSource.reject.series(ds.StudyInstanceUID, ds.SeriesInstanceUID);
DisplaySetService.deleteDisplaySet(displaySetInstanceUID);
UIDialogService.dismiss({ id: 'ds-reject-sr' });
UINotificationService.show({
title: 'Reject Report',
message: 'Report rejected successfully',
type: 'success',
});
} catch (error) {
UIDialogService.dismiss({ id: 'ds-reject-sr' });
UINotificationService.show({
title: 'Reject Report',
message: 'Failed to reject report',
type: 'error',
});
}
break;
case 'cancel':
UIDialogService.dismiss({ id: 'ds-reject-sr' });
break;
}
},
},
});
};
} else {
thumbnailProps.canReject = false;
}
}
array.push(thumbnailProps);
}); });
return [...thumbnailDisplaySets, ...thumbnailNoImageDisplaySets]; return [...thumbnailDisplaySets, ...thumbnailNoImageDisplaySets];
@ -442,14 +519,35 @@ function _createStudyBrowserTabs(
const recentStudies = []; const recentStudies = [];
const allStudies = []; const allStudies = [];
// Iterate over each study...
studyDisplayList.forEach(study => { studyDisplayList.forEach(study => {
const displaySetsForStudy = utils.sortBySeriesDate(displaySets.filter( // Find it's display sets
const displaySetsForStudy = displaySets.filter(
ds => ds.StudyInstanceUID === study.studyInstanceUid ds => ds.StudyInstanceUID === study.studyInstanceUid
)); );
// Sort them
const sortedDisplaySetsForStudy = utils.sortBySeriesDate(displaySetsForStudy);
/* Sort by series number, then by series date
displaySetsForStudy.sort((a, b) => {
if (a.seriesNumber !== b.seriesNumber) {
return a.seriesNumber - b.seriesNumber;
}
const seriesDateA = Date.parse(a.seriesDate);
const seriesDateB = Date.parse(b.seriesDate);
return seriesDateA - seriesDateB;
});
*/
// Map the study to it's tab/view representation
const tabStudy = Object.assign({}, study, { const tabStudy = Object.assign({}, study, {
displaySets: displaySetsForStudy, displaySets: displaySetsForStudy,
}); });
// Add the "tab study" to the 'primary', 'recent', and/or 'all' tab group(s)
if (primaryStudyInstanceUIDs.includes(study.studyInstanceUid)) { if (primaryStudyInstanceUIDs.includes(study.studyInstanceUid)) {
primaryStudies.push(tabStudy); primaryStudies.push(tabStudy);
allStudies.push(tabStudy); allStudies.push(tabStudy);

View File

@ -18,9 +18,7 @@ function WrappedPanelStudyBrowserTracking({
extensionManager, extensionManager,
servicesManager, servicesManager,
}) { }) {
// TODO: This should be made available a different way; route should have const dataSource = extensionManager.getActiveDataSource()[0];
// already determined our datasource
const dataSource = extensionManager.getDataSources('dicomweb')[0];
const _getStudiesForPatientByStudyInstanceUID = getStudiesForPatientByStudyInstanceUID.bind( const _getStudiesForPatientByStudyInstanceUID = getStudiesForPatientByStudyInstanceUID.bind(
null, null,
dataSource dataSource
@ -37,6 +35,8 @@ function WrappedPanelStudyBrowserTracking({
<PanelStudyBrowserTracking <PanelStudyBrowserTracking
MeasurementService={servicesManager.services.MeasurementService} MeasurementService={servicesManager.services.MeasurementService}
DisplaySetService={servicesManager.services.DisplaySetService} DisplaySetService={servicesManager.services.DisplaySetService}
UIDialogService={servicesManager.services.UIDialogService}
UINotificationService={servicesManager.services.UINotificationService}
dataSource={dataSource} dataSource={dataSource}
getImageSrc={_getImageSrcFromImageId} getImageSrc={_getImageSrcFromImageId}
getStudiesForPatientByStudyInstanceUID={ getStudiesForPatientByStudyInstanceUID={

View File

@ -16,6 +16,7 @@ function create({
query, query,
retrieve, retrieve,
store, store,
reject,
retrieveSeriesMetadata, retrieveSeriesMetadata,
deleteStudyMetadataPromise, deleteStudyMetadataPromise,
getImageIdsForDisplaySet, getImageIdsForDisplaySet,
@ -54,9 +55,12 @@ function create({
}, },
}; };
const defaultReject = {};
return { return {
query: query || defaultQuery, query: query || defaultQuery,
retrieve: retrieve || defaultRetrieve, retrieve: retrieve || defaultRetrieve,
reject: reject || defaultReject,
store: store || defaultStore, store: store || defaultStore,
getImageIdsForDisplaySet, getImageIdsForDisplaySet,
retrieveSeriesMetadata, retrieveSeriesMetadata,

View File

@ -64,6 +64,26 @@ export default class DisplaySetService {
return displaySet; return displaySet;
} }
deleteDisplaySet(displaySetInstanceUID) {
const { activeDisplaySets } = this;
const displaySetCacheIndex = displaySetCache.findIndex(
ds => ds.displaySetInstanceUID === displaySetInstanceUID
);
const activeDisplaySetsIndex = activeDisplaySets.findIndex(
ds => ds.displaySetInstanceUID === displaySetInstanceUID
);
displaySetCache.splice(displaySetCacheIndex, 1);
activeDisplaySets.splice(activeDisplaySetsIndex, 1);
this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets);
this._broadcastEvent(EVENTS.DISPLAY_SETS_REMOVED, {
displaySetInstanceUIDs: [displaySetInstanceUID],
});
}
/** /**
* @param {string} displaySetInstanceUID * @param {string} displaySetInstanceUID
* @returns {object} displaySet * @returns {object} displaySet

View File

@ -1,6 +1,7 @@
const EVENTS = { const EVENTS = {
DISPLAY_SETS_ADDED: 'event::displaySetService:displaySetsAdded', DISPLAY_SETS_ADDED: 'event::displaySetService:displaySetsAdded',
DISPLAY_SETS_CHANGED: 'event::displaySetService:displaySetsChanged', DISPLAY_SETS_CHANGED: 'event::displaySetService:displaySetsChanged',
DISPLAY_SETS_REMOVED: 'event::displaySetService:displaySetsRemoved',
}; };
export default EVENTS; export default EVENTS;

View File

@ -5,14 +5,13 @@ import PropTypes from 'prop-types';
import { Typography, Icon } from '..'; import { Typography, Icon } from '..';
const CloseButton = ({ onClick }) => { const CloseButton = ({ onClick }) => {
const theme = 'bg-transparent fill-primary-active';
const outline = 'outline-none focus:outline-none';
const flex = 'flex h-full';
return ( return (
<button className={classNames(flex, theme, 'border-0')} onClick={onClick}> <Icon
<Icon name="close" className={classNames(theme, outline, 'h-3 w-3')} /> data-cy="close-button"
</button> onClick={onClick}
name="close"
className="cursor-pointer text-primary-active w-6 h-6"
/>
); );
}; };

View File

@ -24,6 +24,8 @@ const ThumbnailList = ({
seriesDate, seriesDate,
viewportIdentificator, viewportIdentificator,
isTracked, isTracked,
canReject,
onReject,
imageSrc, imageSrc,
imageAltText, imageAltText,
}) => { }) => {
@ -82,6 +84,8 @@ const ThumbnailList = ({
modalityTooltip={_getModalityTooltip(modality)} modalityTooltip={_getModalityTooltip(modality)}
seriesDate={seriesDate} seriesDate={seriesDate}
description={description} description={description}
canReject={canReject}
onReject={onReject}
onClick={() => onThumbnailClick(displaySetInstanceUID)} onClick={() => onThumbnailClick(displaySetInstanceUID)}
onDoubleClick={() => onDoubleClick={() =>
onThumbnailDoubleClick(displaySetInstanceUID) onThumbnailDoubleClick(displaySetInstanceUID)
@ -113,7 +117,10 @@ ThumbnailList.propTypes = {
'thumbnailTracked', 'thumbnailTracked',
'thumbnailNoImage', 'thumbnailNoImage',
]).isRequired, ]).isRequired,
viewportIdentificator: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), viewportIdentificator: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array,
]),
isTracked: PropTypes.bool, isTracked: PropTypes.bool,
/** /**
* Data the thumbnail should expose to a receiving drop target. Use a matching * Data the thumbnail should expose to a receiving drop target. Use a matching

View File

@ -12,12 +12,14 @@ const ThumbnailNoImage = ({
modalityTooltip, modalityTooltip,
onClick, onClick,
onDoubleClick, onDoubleClick,
canReject,
onReject,
dragData, dragData,
isActive, isActive,
}) => { }) => {
const [collectedProps, drag, dragPreview] = useDrag({ const [collectedProps, drag, dragPreview] = useDrag({
item: { ...dragData }, item: { ...dragData },
canDrag: function(monitor) { canDrag: function (monitor) {
return Object.keys(dragData).length !== 0; return Object.keys(dragData).length !== 0;
}, },
}); });
@ -51,8 +53,11 @@ const ThumbnailNoImage = ({
</Tooltip> </Tooltip>
<span className="ml-4 text-base text-blue-300">{seriesDate}</span> <span className="ml-4 text-base text-blue-300">{seriesDate}</span>
</div> </div>
<div className="ml-12 text-base text-white break-all"> <div className="flex flex-row">
{description} {canReject && <Icon name="old-trash" className="ml-4 w-3 text-red-500" onClick={onReject} />}
<div className="ml-4 text-base text-white break-all">
{description}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -16,11 +16,12 @@ window.config = {
qidoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', qidoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs',
wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs',
qidoSupportsIncludeField: true, qidoSupportsIncludeField: true,
supportsReject: true,
imageRendering: 'wadors', imageRendering: 'wadors',
thumbnailRendering: 'wadors', thumbnailRendering: 'wadors',
enableStudyLazyLoad: true, enableStudyLazyLoad: true,
supportsFuzzyMatching: true, supportsFuzzyMatching: true,
supportsWildcard: true supportsWildcard: true,
}, },
}, },
], ],

View File

@ -16,11 +16,12 @@ window.config = {
qidoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', qidoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs',
wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs',
qidoSupportsIncludeField: true, qidoSupportsIncludeField: true,
supportsReject: true,
imageRendering: 'wadors', imageRendering: 'wadors',
thumbnailRendering: 'wadors', thumbnailRendering: 'wadors',
enableStudyLazyLoad: true, enableStudyLazyLoad: true,
supportsFuzzyMatching: true, supportsFuzzyMatching: true,
supportsWildcard: true supportsWildcard: true,
}, },
}, },
], ],