Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
NotificationMiddlewareContext,
} from './services/NotificationMiddleware'
import { CustomError, errorTypes } from './utils/CustomError'
import { getProjectStorePath, isProjectsPath, RoutePaths } from './utils/routes'
import { joinUrl, normalizeServerUrl } from './utils/url'

function ParametrizedCaseViewer({
Expand Down Expand Up @@ -103,8 +104,8 @@
}
})
} else {
if (window.location.pathname.includes('/projects/')) {
const pathname = window.location.pathname.split('/study/')[0]
if (isProjectsPath(window.location.pathname)) {
const pathname = getProjectStorePath(window.location.pathname)

Check warning on line 108 in src/App.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=ImagingDataCommons_slim&issues=AZ7xfwcbJa-Yax3eQidc&open=AZ7xfwcbJa-Yax3eQidc&pullRequest=392
const pathUrl = `${gcpBaseUrl}${pathname}/dicomWeb`
serverSettings.url = pathUrl
}
Expand Down Expand Up @@ -540,7 +541,7 @@
<BrowserRouter basename={this.props.config.path}>
<Routes>
<Route
path="/"
path={RoutePaths.ROOT}
element={
<Layout style={layoutStyle}>
<Header
Expand All @@ -563,7 +564,7 @@
}
/>
<Route
path="/studies/:studyInstanceUID/*"
path={RoutePaths.STUDY}
element={
<SettingsProvider>
<Layout style={layoutStyle}>
Expand Down Expand Up @@ -593,7 +594,7 @@
}
/>
<Route
path="/projects/:project/locations/:location/datasets/:dataset/dicomStores/:dicomStore/study/:studyInstanceUID/*"
path={RoutePaths.GCP_STUDY}
element={
<SettingsProvider>
<Layout style={layoutStyle}>
Expand Down Expand Up @@ -623,7 +624,7 @@
}
/>
<Route
path="/logout"
path={RoutePaths.LOGOUT}
element={
<Layout style={layoutStyle}>
<Header
Expand Down
5 changes: 3 additions & 2 deletions src/auth/OidcManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import NotificationMiddleware, {
NotificationMiddlewareContext,
} from '../services/NotificationMiddleware'
import { CustomError, errorTypes } from '../utils/CustomError'
import { buildLogoutPath } from '../utils/routes'
import { isAuthorizationCodeInUrl } from '../utils/url'
import type { AuthManager, SignInCallback, User } from '.'

Expand Down Expand Up @@ -63,7 +64,7 @@ export default class OidcManager implements AuthManager {
loadUserInfo: true,
automaticSilentRenew: true,
revokeAccessTokenOnSignout: true,
post_logout_redirect_uri: `${baseUri}/logout`,
post_logout_redirect_uri: buildLogoutPath(baseUri),
})
if (
settings.endSessionEndpoint !== null &&
Expand Down Expand Up @@ -95,7 +96,7 @@ export default class OidcManager implements AuthManager {
loadUserInfo: true,
automaticSilentRenew: true,
revokeAccessTokenOnSignout: true,
post_logout_redirect_uri: `${baseUri}/logout`,
post_logout_redirect_uri: buildLogoutPath(baseUri),
metadata,
})
}
Expand Down
35 changes: 15 additions & 20 deletions src/components/CaseViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import type { Slide } from '../data/slides'
import { StorageClasses } from '../data/uids'
import { useSlides } from '../hooks/useSlides'
import { type RouteComponentProps, withRouter } from '../utils/router'
import {
buildSeriesPath,
hasSeriesInPath,
isProjectsPath,
parseSeriesInstanceUID,
RoutePaths,
withSeriesInProjectPath,
} from '../utils/routes'
import ClinicalTrial from './ClinicalTrial'
import Patient from './Patient'
import SlideList from './SlideList'
Expand Down Expand Up @@ -215,22 +223,14 @@ function Viewer(props: ViewerProps): JSX.Element | null {
seriesInstanceUID: string
}): void => {
console.info(`switch to series "${seriesInstanceUID}"`)
let urlPath = `/studies/${studyInstanceUID}/series/${seriesInstanceUID}`
let urlPath = buildSeriesPath(studyInstanceUID, seriesInstanceUID)

if (location.pathname.includes('/projects/')) {
urlPath = location.pathname
if (!location.pathname.includes('/series/')) {
urlPath += `/series/${seriesInstanceUID}`
} else {
urlPath = urlPath.replace(
/\/series\/[^/]+/,
`/series/${seriesInstanceUID}`,
)
}
if (isProjectsPath(location.pathname)) {
urlPath = withSeriesInProjectPath(location.pathname, seriesInstanceUID)
}

if (
location.pathname.includes('/series/') &&
hasSeriesInPath(location.pathname) &&
location.search !== null &&
location.search !== undefined
) {
Expand Down Expand Up @@ -259,13 +259,8 @@ function Viewer(props: ViewerProps): JSX.Element | null {
* Otherwise select the first series correspondent to
* the first slide contained in the study.
*/
let selectedSeriesInstanceUID: string
if (location.pathname.includes('series/')) {
const seriesFragment = location.pathname.split('series/')[1]
selectedSeriesInstanceUID = seriesFragment.includes('/')
? seriesFragment.split('/')[0]
: seriesFragment
} else {
let selectedSeriesInstanceUID = parseSeriesInstanceUID(location.pathname)
if (selectedSeriesInstanceUID === '') {
selectedSeriesInstanceUID = volumeInstances[0].SeriesInstanceUID
}

Expand Down Expand Up @@ -316,7 +311,7 @@ function Viewer(props: ViewerProps): JSX.Element | null {

<Routes>
<Route
path="/series/:seriesInstanceUID"
path={RoutePaths.SERIES}
element={
<ParametrizedSlideViewer
clients={props.clients}
Expand Down
28 changes: 10 additions & 18 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ import NotificationMiddleware, {
} from '../services/NotificationMiddleware'
import type { CustomError } from '../utils/CustomError'
import { type RouteComponentProps, withRouter } from '../utils/router'
import {
isGcpDicomStorePath,
isViewerPath,
parseSeriesInstanceUID,
} from '../utils/routes'
import { normalizeServerUrl } from '../utils/url'
import Button from './Button'
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser'
Expand All @@ -45,8 +50,6 @@ const aboutModalCopyTooltips: [React.ReactNode, React.ReactNode] = [
'Copied!',
]

const DICOM_TAG_BROWSER_PATHS = ['/studies/', '/study/', '/projects/'] as const

const aboutModalStyles: Record<string, React.CSSProperties> = {
container: {
textAlign: 'center',
Expand Down Expand Up @@ -213,12 +216,7 @@ class Header extends React.Component<HeaderProps, HeaderState> {
}
}
const pathNorm = trimmedUrl.startsWith('/') ? trimmedUrl : `/${trimmedUrl}`
return (
pathNorm.includes('/projects/') &&
pathNorm.includes('/locations/') &&
pathNorm.includes('/datasets/') &&
pathNorm.includes('/dicomStores/')
)
return isGcpDicomStorePath(pathNorm)
}

static handleUserMenuButtonClick(e: React.SyntheticEvent): void {
Expand Down Expand Up @@ -367,13 +365,9 @@ class Header extends React.Component<HeaderProps, HeaderState> {
handleDicomTagBrowserButtonClick = (): void => {
const width = window.innerWidth - 200

let seriesInstanceUID = ''
if (this.props.location.pathname.includes('series/')) {
const seriesFragment = this.props.location.pathname.split('series/')[1]
seriesInstanceUID = seriesFragment.includes('/')
? seriesFragment.split('/')[0]
: seriesFragment
}
const seriesInstanceUID = parseSeriesInstanceUID(
this.props.location.pathname,
)

Modal.info({
title: 'DICOM Tag Browser',
Expand Down Expand Up @@ -627,9 +621,7 @@ class Header extends React.Component<HeaderProps, HeaderState> {
</Badge>
)

const showDicomTagBrowser = DICOM_TAG_BROWSER_PATHS.some((path) =>
this.props.location.pathname.includes(path),
)
const showDicomTagBrowser = isViewerPath(this.props.location.pathname)

const dicomTagBrowserButton = showDicomTagBrowser ? (
<Button
Expand Down
3 changes: 2 additions & 1 deletion src/components/Worklist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import NotificationMiddleware, {
import { CustomError, errorTypes } from '../utils/CustomError'
import { logger } from '../utils/logger'
import { type RouteComponentProps, withRouter } from '../utils/router'
import { buildStudyPath } from '../utils/routes'
import { parseDate, parseName, parseSex, parseTime } from '../utils/values'

// Standalone function for row key generation
Expand Down Expand Up @@ -125,7 +126,7 @@ class Worklist extends React.Component<WorklistProps, WorklistState> {
_event: React.SyntheticEvent,
study: dmv.metadata.Study,
): void => {
this.props.navigate(`/studies/${study.StudyInstanceUID}`)
this.props.navigate(buildStudyPath(study.StudyInstanceUID))
}

fetchData = ({
Expand Down
115 changes: 115 additions & 0 deletions src/utils/__tests__/routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
buildLogoutPath,
buildSeriesPath,
buildStudyPath,
getProjectStorePath,
hasSeriesInPath,
isGcpDicomStorePath,
isProjectsPath,
isViewerPath,
parseSeriesInstanceUID,
RouteParams,
RoutePaths,
withSeriesInProjectPath,
} from '../routes'

const studyUID = '1.2.3'
const seriesUID = '4.5.6'
const otherSeriesUID = '7.8.9'

const gcpStorePath =
'/projects/idc-sandbox-000/locations/us-central1/datasets/dev/dicomStores/store'

describe('route templates', () => {
it('embed the configured parameter names', () => {
expect(RoutePaths.STUDY).toBe(`/studies/:${RouteParams.STUDY_INSTANCE_UID}/*`)
expect(RoutePaths.SERIES).toBe(
`/series/:${RouteParams.SERIES_INSTANCE_UID}`,
)
expect(RoutePaths.GCP_STUDY).toBe(
'/projects/:project/locations/:location/datasets/:dataset/dicomStores/:dicomStore/study/:studyInstanceUID/*',
)
expect(RoutePaths.ROOT).toBe('/')
expect(RoutePaths.LOGOUT).toBe('/logout')
})
})

describe('path builders', () => {
it('builds study paths', () => {
expect(buildStudyPath(studyUID)).toBe('/studies/1.2.3')
})

it('builds series paths', () => {
expect(buildSeriesPath(studyUID, seriesUID)).toBe(
'/studies/1.2.3/series/4.5.6',
)
})

it('builds logout paths', () => {
expect(buildLogoutPath('https://example.org')).toBe(
'https://example.org/logout',
)
})
})

describe('parseSeriesInstanceUID', () => {
it('returns the series UID at the end of a path', () => {
expect(parseSeriesInstanceUID('/studies/1.2.3/series/4.5.6')).toBe(seriesUID)
})

it('returns the series UID followed by further segments', () => {
expect(parseSeriesInstanceUID('/studies/1.2.3/series/4.5.6/extra')).toBe(
seriesUID,
)
})

it('returns an empty string when no series is present', () => {
expect(parseSeriesInstanceUID('/studies/1.2.3')).toBe('')
})
})

describe('path predicates', () => {
it('detects series in a path', () => {
expect(hasSeriesInPath('/studies/1.2.3/series/4.5.6')).toBe(true)
expect(hasSeriesInPath('/studies/1.2.3')).toBe(false)
})

it('detects GCP project paths', () => {
expect(isProjectsPath(`${gcpStorePath}/study/1.2.3`)).toBe(true)
expect(isProjectsPath('/studies/1.2.3')).toBe(false)
})

it('detects viewer paths', () => {
expect(isViewerPath('/studies/1.2.3')).toBe(true)
expect(isViewerPath(`${gcpStorePath}/study/1.2.3`)).toBe(true)
expect(isViewerPath('/')).toBe(false)
})

it('detects GCP DICOM store paths', () => {
expect(isGcpDicomStorePath(gcpStorePath)).toBe(true)
expect(isGcpDicomStorePath('/projects/foo')).toBe(false)
})
})

describe('getProjectStorePath', () => {
it('returns the store path up to the study segment', () => {
expect(getProjectStorePath(`${gcpStorePath}/study/1.2.3`)).toBe(gcpStorePath)
})
})

describe('withSeriesInProjectPath', () => {
it('appends a series segment when none is present', () => {
expect(
withSeriesInProjectPath(`${gcpStorePath}/study/1.2.3`, seriesUID),
).toBe(`${gcpStorePath}/study/1.2.3/series/4.5.6`)
})

it('replaces an existing series segment', () => {
expect(
withSeriesInProjectPath(
`${gcpStorePath}/study/1.2.3/series/${otherSeriesUID}`,
seriesUID,
),
).toBe(`${gcpStorePath}/study/1.2.3/series/4.5.6`)
})
})
Loading
Loading