Skip to content

Commit 6fbd1a7

Browse files
Radiology/image link column in the Patient List (#2939)
* feat: surface OMOP image_occurrence for the patient list (#2849) - Add @img placeholder->table mapping (image_occurrence joined to person via person_id) in query-gen-svc and analytics-svc, so image_occurrence can be configured as a patient-list interaction with no query-engine code change. - Allow an optional patientlist.link { label } in the PA config schema. * feat: render URL/link columns in the patient list (#2849) Patient-list cells whose value is a valid http(s) URL render as a new-tab link (rel=noopener noreferrer) with an external-link icon; other values render as plain text. Enables the radiology 'Action' column to open an image viewer. - urlUtils.isValidHttpUrl: http/https-only URL validation - PatientListLinkData.vue + ExternalLinkIcon.vue: the link cell - PatientListControl: per-cell dispatch (link vs text) by value - MriConfigAttribute: getPatientListLink/isPatientListLink for the config label
1 parent 665f5d0 commit 6fbd1a7

11 files changed

Lines changed: 294 additions & 1 deletion

File tree

plugins/functions/analytics-svc/src/qe/settings/OldSettings.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ const defaultInterfaceViewsPholderTableMap: PholderTableMapType = {
152152
"@PTOKEN.START": '"CREATED_DATE"',
153153
"@PTOKEN.END": '"CREATED_DATE"',
154154
"@PTOKEN.INTERACTION_TYPE": '"TOKEN"',
155+
"@IMG": "$$SCHEMA$$.image_occurrence",
156+
"@IMG.PATIENT_ID": person_id,
157+
"@IMG.INTERACTION_ID": "image_occurrence_id",
158+
"@IMG.CONDITION_ID": "image_occurrence_id",
159+
"@IMG.PARENT_INTERACT_ID": "image_occurrence_id",
160+
"@IMG.START": "image_occurrence_date",
161+
"@IMG.END": "image_occurrence_date",
162+
"@IMG.INTERACTION_TYPE": "modality_concept_id",
155163
"@PATIENT": "$$SCHEMA$$.person",
156164
"@PATIENT.PATIENT_ID": person_id,
157165
"@PATIENT.DOD": "death_date",
@@ -304,6 +312,14 @@ const defaultPholderTableMap: PholderTableMapType = {
304312
"@PTOKEN.START": '"CREATED_DATE"',
305313
"@PTOKEN.END": '"CREATED_DATE"',
306314
"@PTOKEN.INTERACTION_TYPE": '"TOKEN"',
315+
"@IMG": "$$SCHEMA$$.image_occurrence",
316+
"@IMG.PATIENT_ID": person_id,
317+
"@IMG.INTERACTION_ID": "image_occurrence_id",
318+
"@IMG.CONDITION_ID": "image_occurrence_id",
319+
"@IMG.PARENT_INTERACT_ID": "image_occurrence_id",
320+
"@IMG.START": "image_occurrence_date",
321+
"@IMG.END": "image_occurrence_date",
322+
"@IMG.INTERACTION_TYPE": "modality_concept_id",
307323
"@PATIENT": "$$SCHEMA$$.person",
308324
"@PATIENT.PATIENT_ID": person_id,
309325
"@PATIENT.DOD": "death_date",

plugins/functions/mri-pa-config/src/config/configDefinition.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,18 @@ function _getValidDefinition(cdwConfig) {
304304
mandatory: false,
305305
type: "boolean",
306306
},
307+
{
308+
name: "link",
309+
mandatory: false,
310+
type: "object",
311+
children: [
312+
{
313+
name: "label",
314+
mandatory: false,
315+
type: "string",
316+
},
317+
],
318+
},
307319
{
308320
name: "order",
309321
mandatory: false,

plugins/functions/query-gen-svc/src/qe/settings/OldSettings.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ const defaultInterfaceViewsPholderTableMap: PholderTableMapType = {
152152
"@PTOKEN.START": '"CREATED_DATE"',
153153
"@PTOKEN.END": '"CREATED_DATE"',
154154
"@PTOKEN.INTERACTION_TYPE": '"TOKEN"',
155+
"@IMG": "$$SCHEMA$$.image_occurrence",
156+
"@IMG.PATIENT_ID": person_id,
157+
"@IMG.INTERACTION_ID": "image_occurrence_id",
158+
"@IMG.CONDITION_ID": "image_occurrence_id",
159+
"@IMG.PARENT_INTERACT_ID": "image_occurrence_id",
160+
"@IMG.START": "image_occurrence_date",
161+
"@IMG.END": "image_occurrence_date",
162+
"@IMG.INTERACTION_TYPE": "modality_concept_id",
155163
"@PATIENT": "$$SCHEMA$$.person",
156164
"@PATIENT.PATIENT_ID": person_id,
157165
"@PATIENT.DOD": "death_date",
@@ -304,6 +312,14 @@ const defaultPholderTableMap: PholderTableMapType = {
304312
"@PTOKEN.START": '"CREATED_DATE"',
305313
"@PTOKEN.END": '"CREATED_DATE"',
306314
"@PTOKEN.INTERACTION_TYPE": '"TOKEN"',
315+
"@IMG": "$$SCHEMA$$.image_occurrence",
316+
"@IMG.PATIENT_ID": person_id,
317+
"@IMG.INTERACTION_ID": "image_occurrence_id",
318+
"@IMG.CONDITION_ID": "image_occurrence_id",
319+
"@IMG.PARENT_INTERACT_ID": "image_occurrence_id",
320+
"@IMG.START": "image_occurrence_date",
321+
"@IMG.END": "image_occurrence_date",
322+
"@IMG.INTERACTION_TYPE": "modality_concept_id",
307323
"@PATIENT": "$$SCHEMA$$.person",
308324
"@PATIENT.PATIENT_ID": person_id,
309325
"@PATIENT.DOD": "death_date",

plugins/ui/apps/vue-mri-ui-lib/src/components/PatientListControl.vue

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@
7777
<div v-for="detail in row[attribute.parentPath]" :key="detail" class="cellContainer">
7878
<div class="textContent row-item">
7979
<div class="textContent row-item">
80-
<patientListData :item="detail" :meta="attribute" />
80+
<component
81+
:is="cellComponent(detail, attribute)"
82+
:item="detail"
83+
:meta="attribute"
84+
/>
8185
</div>
8286
</div>
8387
</div>
@@ -96,6 +100,8 @@ import icon from '../lib/ui/app-icon.vue'
96100
import * as types from '../store/mutation-types'
97101
import DropDownMenu from './DropDownMenu.vue'
98102
import PatientListData from './PatientListData.vue'
103+
import PatientListLinkData from './PatientListLinkData.vue'
104+
import { isValidHttpUrl } from '../utils/urlUtils'
99105
100106
function hasProp(obj, prop) {
101107
return Object.prototype.hasOwnProperty.call(obj, prop)
@@ -167,6 +173,7 @@ export default {
167173
path,
168174
parentPath: this.getMriFrontendConfig.getInteractionInstancePath(path),
169175
text: this.getMriFrontendConfig.getAttributeByPath(path).getName(),
176+
link: this.getMriFrontendConfig.getAttributeByPath(path).getPatientListLink(),
170177
}))
171178
.reduce((dict, col) => {
172179
if (dict[col.parentPath]) {
@@ -218,6 +225,12 @@ export default {
218225
},
219226
methods: {
220227
...mapActions(['populateColumnMenu', 'setColumnWidths']),
228+
// Render a cell as a new-tab link when it is configured as a link column
229+
// or its value is a valid http(s) URL; otherwise render plain text.
230+
cellComponent(detail, attribute) {
231+
const value = detail ? detail[attribute.path] : null
232+
return attribute.link || isValidHttpUrl(value) ? 'patientListLinkData' : 'patientListData'
233+
},
221234
...mapMutations([types.PL_REMOVE_SELECTED_ATTRIBUTE]),
222235
renderWidths() {
223236
const columnWidths = this.computeBaseWidth(this.tableColumns)
@@ -456,6 +469,7 @@ export default {
456469
icon,
457470
DropDownMenu,
458471
PatientListData,
472+
PatientListLinkData,
459473
},
460474
}
461475
</script>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<template>
2+
<a
3+
v-if="isValidLink"
4+
class="patient-list-link"
5+
:href="url"
6+
target="_blank"
7+
rel="noopener noreferrer"
8+
>
9+
<span>{{ label }}</span>
10+
<ExternalLinkIcon class="patient-list-link__icon" />
11+
</a>
12+
<span v-else>{{ text }}</span>
13+
</template>
14+
15+
<script lang="ts">
16+
import ExternalLinkIcon from './icons/ExternalLinkIcon.vue'
17+
import { isValidHttpUrl } from '../utils/urlUtils'
18+
19+
// Generic fallback link text used only when the attribute config does not
20+
// supply a `patientlist.link.label`. A config-provided label (e.g. "OHIF
21+
// viewer" for the radiology column) always takes precedence.
22+
const DEFAULT_LINK_LABEL = 'Open'
23+
24+
function hasProp(obj, prop) {
25+
return Object.prototype.hasOwnProperty.call(obj, prop)
26+
}
27+
28+
export default {
29+
name: 'patientListLinkData',
30+
components: { ExternalLinkIcon },
31+
props: ['item', 'meta'],
32+
computed: {
33+
url(): string | null {
34+
if (this.item && hasProp(this.item, this.meta.path)) {
35+
return this.item[this.meta.path]
36+
}
37+
return null
38+
},
39+
text(): string {
40+
return this.url == null ? '' : String(this.url)
41+
},
42+
label(): string {
43+
return this.meta.link && this.meta.link.label ? this.meta.link.label : DEFAULT_LINK_LABEL
44+
},
45+
isValidLink(): boolean {
46+
return isValidHttpUrl(this.url)
47+
},
48+
},
49+
}
50+
</script>
51+
52+
<style scoped>
53+
.patient-list-link {
54+
display: inline-flex;
55+
align-items: center;
56+
gap: 4px;
57+
}
58+
.patient-list-link__icon {
59+
flex: 0 0 auto;
60+
}
61+
</style>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { shallowMount } from '@vue/test-utils'
2+
import { describe, expect, it } from 'vitest'
3+
import PatientListLinkData from '../PatientListLinkData.vue'
4+
5+
const PATH = 'imageoccurrence.attributes.action'
6+
const meta = (link?: { label: string }) => ({ path: PATH, link })
7+
8+
describe('PatientListLinkData.vue', () => {
9+
it('renders a new-tab anchor for a valid URL', () => {
10+
const url = 'https://viewer.local:8042/ohif/viewer?StudyInstanceUIDs=1.2.3'
11+
const wrapper = shallowMount(PatientListLinkData as any, {
12+
props: { item: { [PATH]: url }, meta: meta() },
13+
})
14+
const anchor = wrapper.find('a')
15+
expect(anchor.exists()).toBe(true)
16+
expect(anchor.attributes('href')).toBe(url)
17+
expect(anchor.attributes('target')).toBe('_blank')
18+
expect(anchor.attributes('rel')).toBe('noopener noreferrer')
19+
})
20+
21+
it('defaults the link label to a generic "Open" when config has none', () => {
22+
const wrapper = shallowMount(PatientListLinkData as any, {
23+
props: { item: { [PATH]: 'https://example.com' }, meta: meta() },
24+
})
25+
expect(wrapper.find('a').text()).toContain('Open')
26+
})
27+
28+
it('uses the config-driven label when present', () => {
29+
const wrapper = shallowMount(PatientListLinkData as any, {
30+
props: { item: { [PATH]: 'https://example.com' }, meta: meta({ label: 'Open image' }) },
31+
})
32+
expect(wrapper.find('a').text()).toContain('Open image')
33+
})
34+
35+
it('renders empty (no anchor) when the value is missing', () => {
36+
const wrapper = shallowMount(PatientListLinkData as any, { props: { item: {}, meta: meta() } })
37+
expect(wrapper.find('a').exists()).toBe(false)
38+
expect(wrapper.text()).toBe('')
39+
})
40+
41+
it('renders the raw value as text (no anchor) for a non-URL / unsafe value', () => {
42+
const wrapper = shallowMount(PatientListLinkData as any, {
43+
props: { item: { [PATH]: 'javascript:alert(1)' }, meta: meta() },
44+
})
45+
expect(wrapper.find('a').exists()).toBe(false)
46+
expect(wrapper.text()).toContain('javascript:alert(1)')
47+
})
48+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<template>
2+
<svg
3+
width="16px"
4+
height="16px"
5+
viewBox="0 0 24 24"
6+
version="1.1"
7+
xmlns="http://www.w3.org/2000/svg"
8+
aria-hidden="true"
9+
focusable="false"
10+
>
11+
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
12+
<path
13+
fill-rule="evenodd"
14+
clip-rule="evenodd"
15+
d="M14 3a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V5.414l-8.293 8.293a1 1 0 0 1-1.414-1.414L18.586 4H15a1 1 0 0 1-1-1ZM5 6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 1 1 2 0v5a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h5a1 1 0 1 1 0 2H5Z"
16+
fill="currentColor"
17+
/>
18+
</g>
19+
</svg>
20+
</template>
21+
<script>
22+
export default {
23+
name: 'ExternalLinkIcon',
24+
}
25+
</script>

plugins/ui/apps/vue-mri-ui-lib/src/lib/MriConfigAttribute.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ MriConfigAttribute.prototype.isVisibleInPatientList = function () {
7878
)
7979
}
8080

81+
/**
82+
* Returns the patient-list link config for this attribute, or undefined.
83+
* When present (e.g. `{ label: "OHIF viewer" }`), the attribute's value is a
84+
* URL that should render as a link opening in a new tab.
85+
*/
86+
MriConfigAttribute.prototype.getPatientListLink = function () {
87+
return this.oInternalConfigAttribute.patientlist && this.oInternalConfigAttribute.patientlist.link
88+
? this.oInternalConfigAttribute.patientlist.link
89+
: undefined
90+
}
91+
8192
MriConfigAttribute.prototype.getOrderInPatientList = function () {
8293
let order = null
8394

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, it } from 'vitest'
2+
import MriConfigAttribute from '../MriConfigAttribute'
3+
4+
const make = (internal: any) =>
5+
new (MriConfigAttribute as any)('imageoccurrence.attributes.action', internal, 'imageoccurrence')
6+
7+
describe('MriConfigAttribute link accessors', () => {
8+
it('getPatientListLink returns the link config when present', () => {
9+
const attr = make({ patientlist: { visible: true, link: { label: 'OHIF viewer' } } })
10+
expect(attr.getPatientListLink()).toEqual({ label: 'OHIF viewer' })
11+
})
12+
13+
it('getPatientListLink returns undefined when there is no link config', () => {
14+
expect(make({ patientlist: { visible: true } }).getPatientListLink()).toBeUndefined()
15+
expect(make({}).getPatientListLink()).toBeUndefined()
16+
})
17+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { isValidHttpUrl } from '../urlUtils'
3+
4+
describe('isValidHttpUrl', () => {
5+
it('accepts http URLs', () => {
6+
expect(isValidHttpUrl('http://example.com')).toBe(true)
7+
})
8+
9+
it('accepts https URLs', () => {
10+
expect(isValidHttpUrl('https://viewer.local:8042/ohif/viewer?StudyInstanceUIDs=1.2.3')).toBe(true)
11+
})
12+
13+
it('rejects non-http(s) schemes', () => {
14+
expect(isValidHttpUrl('javascript:alert(1)')).toBe(false)
15+
expect(isValidHttpUrl('data:text/html,<script>alert(1)</script>')).toBe(false)
16+
expect(isValidHttpUrl('ftp://example.com/file')).toBe(false)
17+
expect(isValidHttpUrl('file:///etc/passwd')).toBe(false)
18+
})
19+
20+
it('rejects empty, whitespace, and null-ish values', () => {
21+
expect(isValidHttpUrl('')).toBe(false)
22+
expect(isValidHttpUrl(' ')).toBe(false)
23+
expect(isValidHttpUrl(null)).toBe(false)
24+
expect(isValidHttpUrl(undefined)).toBe(false)
25+
})
26+
27+
it('rejects non-string values', () => {
28+
expect(isValidHttpUrl(123)).toBe(false)
29+
expect(isValidHttpUrl({})).toBe(false)
30+
expect(isValidHttpUrl(['http://example.com'])).toBe(false)
31+
})
32+
33+
it('rejects malformed URLs', () => {
34+
expect(isValidHttpUrl('not a url')).toBe(false)
35+
expect(isValidHttpUrl('http://')).toBe(false)
36+
expect(isValidHttpUrl('://missing-scheme')).toBe(false)
37+
})
38+
39+
it('trims surrounding whitespace before validating', () => {
40+
expect(isValidHttpUrl(' https://example.com ')).toBe(true)
41+
})
42+
})

0 commit comments

Comments
 (0)