Skip to content
Merged
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
67 changes: 67 additions & 0 deletions frontend/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,45 @@ test('layouts move the nodes', async ({ page }) => {
.toBe(true)
})

test('Kamada-Kawai and Stress layouts run in the background worker on a big network', async ({
page,
request,
}) => {
// Regression test: getFrWorker() (metrics.ts) builds the layout worker by
// stringifying module-private functions and dispatching them by their
// literal source names. This suite runs against the production build
// (see playwright.config.ts), where the bundler's minifier renames those
// functions; the dispatcher's hardcoded names didn't follow, so "kk" and
// "stress" threw "distanceLayout is not defined" inside the worker and
// silently failed (runComputedLayout only reports it into #layoutStatus,
// it never throws or logs). That only happens once a network is big
// enough to take the worker path (> 60 nodes) - a small example never
// exercised it.
const n = 80
const edges = Array.from({ length: n - 1 }, (_, i) => ({ source: `N${i}`, target: `N${i + 1}` }))
const r = await request.post('/api/external', { data: { name: 'Layout worker test', edges } })
expect(r.ok()).toBe(true)
const { url } = (await r.json()) as { url: string }
await page.goto(url)
await expect.poll(() => nodeCount(page)).toBe(n)

await page.locator('#sideTabDisplay').click()
await page.locator('#layoutSection h3').click()
await expect(page.locator('#btnRunLayout')).toBeVisible()

for (const kind of ['kk', 'stress']) {
const before = await positions(page)
await selectValue(page, '#layoutSelect', kind)
await page.locator('#btnRunLayout').click()
await expect
.poll(async () => JSON.stringify(await positions(page)) !== JSON.stringify(before), {
timeout: 20_000,
})
.toBe(true)
await expect(page.locator('#layoutStatus')).toBeEmpty()
}
})

test('tabs switch views, 3D view activates', async ({ page }) => {
await openExample(page)
for (const [tab, view] of [
Expand Down Expand Up @@ -135,3 +174,31 @@ test('unknown example shows an error, not a crash', async ({ page }) => {
expect(await nodeCount(page)).toBe(0)
void EXAMPLE
})

test('uploading a non-NORMA file is rejected with a warning before it reaches the canvas', async ({
page,
}) => {
await page.goto('/')
await page.locator('#filesSection h3').click()
await page.setInputFiles('#normaFileInput', {
name: 'export.json',
mimeType: 'application/json',
buffer: Buffer.from('{"nodes":["A","B"],"edges":[["A","B"]]}'),
})
await expect(page.locator('#normaStatus .note.error')).toContainText(/looks like JSON/)
await expect(page.locator('#libNetworks .lib-empty')).toBeVisible()
await expect(page.locator('#libNetworks .lib-row')).toHaveCount(0)
expect(await nodeCount(page)).toBe(0)
})

test('uploading a valid network file is accepted and ready to show', async ({ page }) => {
await page.goto('/')
await page.locator('#filesSection h3').click()
await page.setInputFiles('#normaFileInput', {
name: 'network.txt',
mimeType: 'text/plain',
buffer: Buffer.from('Source\tTarget\tWeight\nA\tB\t1\nB\tC\t2\n'),
})
await expect(page.locator('#normaStatus .note.ok').first()).toContainText(/added/i)
await expect(page.locator('#libNetworks')).not.toBeEmpty()
})
37 changes: 20 additions & 17 deletions frontend/norma.html
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ <h3 role="button" tabindex="0" aria-expanded="false">Reactome<button type="butto
<div class="row"><button id="btnReactomeSearch" data-db-run="reactome">Search pathways</button></div>
<div id="reactomePickRow" hidden>
<select id="reactomePathway" size="5" aria-label="Pathway" class="db-pick"></select>
<button type="button" class="db-pick-copy" id="btnReactomeCopy">Copy selected</button>
</div>
<details class="string-more"><summary>Options</summary>
<div class="row">
Expand Down Expand Up @@ -387,6 +388,7 @@ <h3 role="button" tabindex="0" aria-expanded="false">NDEx<button type="button" c
<div class="row"><button id="btnNdexSearch" data-db-run="ndex">Search NDEx</button></div>
<div id="ndexPickRow" hidden>
<select id="ndexNetwork" size="6" aria-label="Network" class="db-pick"></select>
<button type="button" class="db-pick-copy" id="btnNdexCopy">Copy selected</button>
</div>
<p class="hint-line">Node attributes with a few distinct values (2–60) become groupings; the NDEx layout is kept.</p>
<div class="row stack">
Expand Down Expand Up @@ -431,6 +433,7 @@ <h3 role="button" tabindex="0" aria-expanded="false">Gene Ontology<button type="
<div id="goPickRow" hidden>
<input id="goModelFilter" class="text-input" type="search" placeholder="Filter models by title" autocomplete="off" style="margin-top:6px;">
<select id="goModel" size="6" aria-label="GO-CAM model" class="db-pick"></select>
<button type="button" class="db-pick-copy" id="btnGoCopy">Copy selected</button>
</div>
<input id="goModelId" class="text-input" type="text" placeholder="or a model ID, e.g. 5323da1800000002" autocomplete="off" style="margin-top:6px;">
<div class="row"><button class="primary" id="btnGoModelFetch" data-db-run="go">Fetch GO-CAM network</button></div>
Expand Down Expand Up @@ -1033,6 +1036,23 @@ <h3 role="button" tabindex="0" aria-expanded="false">Performance<button type="bu
</div>

<div id="panelExport" class="side-panel" role="tabpanel" aria-labelledby="sideTabExport" hidden>
<div class="section collapsible collapsed" data-tint="exsave">
<h3 role="button" tabindex="0" aria-expanded="false">Save your work<button type="button" class="helplink" data-help="help-save" aria-label="Help: Save your work">?</button></h3>
<div class="menu-sep"><span>View file (.json) <span class="lib-hint">the current view</span></span></div>
<div class="row" style="margin-top:0;">
<button id="btnExport">Save view file</button>
</div>
<div class="menu-sep"><span>Session file (.json) <span class="lib-hint">all views and files</span></span></div>
<div class="row" style="margin-top:0;">
<button type="button" id="btnSessionSave">Save session file</button>
</div>
<div id="sessionSaveStatus" class="status" role="status" aria-live="polite"></div>
<div class="menu-sep"><span>Settings file (.json) <span class="lib-hint">display settings only</span></span></div>
<div class="row" style="margin-top:0;">
<button id="btnExportConfig">Save settings file</button>
</div>
</div>

<div class="section collapsible collapsed" data-tint="exfiles">
<h3 role="button" tabindex="0" aria-expanded="false">NORMA files<button type="button" class="helplink" data-help="help-save" aria-label="Help: NORMA files">?</button></h3>
<p class="db-tag">Tab-separated files that NORMA (and the original NORMA) can read again.</p>
Expand Down Expand Up @@ -1073,23 +1093,6 @@ <h3 role="button" tabindex="0" aria-expanded="false">Other tools<button type="bu
<div id="otherExportStatus" class="status" role="status" aria-live="polite"></div>
</div>

<div class="section collapsible collapsed" data-tint="exsave">
<h3 role="button" tabindex="0" aria-expanded="false">Save your work<button type="button" class="helplink" data-help="help-save" aria-label="Help: Save your work">?</button></h3>
<div class="menu-sep"><span>View file (.json) <span class="lib-hint">the current view</span></span></div>
<div class="row" style="margin-top:0;">
<button id="btnExport">Save view file</button>
</div>
<div class="menu-sep"><span>Session file (.json) <span class="lib-hint">all views and files</span></span></div>
<div class="row" style="margin-top:0;">
<button type="button" id="btnSessionSave">Save session file</button>
</div>
<div id="sessionSaveStatus" class="status" role="status" aria-live="polite"></div>
<div class="menu-sep"><span>Settings file (.json) <span class="lib-hint">display settings only</span></span></div>
<div class="row" style="margin-top:0;">
<button id="btnExportConfig">Save settings file</button>
</div>
</div>

<div class="section collapsible collapsed" data-tint="exarena">
<h3 role="button" tabindex="0" aria-expanded="false">Arena3D<button type="button" class="helplink" data-help="help-arena3d" aria-label="Help: Arena3D">?</button></h3>
<p class="db-tag">Each group becomes a layer.</p>
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/api/wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
if (payload.length > API_MAX_BYTES) throw new Error('The payload is larger than 50 MB.')
try {
payload = JSON.parse(payload)
} catch (e) {

Check warning on line 148 in frontend/src/api/wiring.ts

View workflow job for this annotation

GitHub Actions / frontend

'e' is defined but never used

Check warning on line 148 in frontend/src/api/wiring.ts

View workflow job for this annotation

GitHub Actions / frontend

'e' is defined but never used
throw new Error('The payload is not valid JSON.')
}
}
Expand Down Expand Up @@ -326,7 +326,7 @@
let text
try {
text = b64urlDecode(hash.get('json'))
} catch (e) {

Check warning on line 329 in frontend/src/api/wiring.ts

View workflow job for this annotation

GitHub Actions / frontend

'e' is defined but never used

Check warning on line 329 in frontend/src/api/wiring.ts

View workflow job for this annotation

GitHub Actions / frontend

'e' is defined but never used
throw new Error(
'the data part of the link (after #json=) is damaged; it may have been cut when the link was copied.'
)
Expand Down Expand Up @@ -359,12 +359,73 @@
const msg = { type: 'norma:ready', version: NORMA_API_VERSION }
try {
if (window.opener) window.opener.postMessage(msg, '*')
} catch (e) {}

Check warning on line 362 in frontend/src/api/wiring.ts

View workflow job for this annotation

GitHub Actions / frontend

'e' is defined but never used

Check warning on line 362 in frontend/src/api/wiring.ts

View workflow job for this annotation

GitHub Actions / frontend

'e' is defined but never used
try {
if (window.parent && window.parent !== window) window.parent.postMessage(msg, '*')
} catch (e) {}

Check warning on line 365 in frontend/src/api/wiring.ts

View workflow job for this annotation

GitHub Actions / frontend

'e' is defined but never used

Check warning on line 365 in frontend/src/api/wiring.ts

View workflow job for this annotation

GitHub Actions / frontend

'e' is defined but never used
}

// Options in a native <select> can't be text-selected by dragging, so the
// "Copy selected" buttons next to the Reactome/NDEx/GO-CAM pickers go
// through the clipboard directly. A permission prompt some browsers/contexts
// show for the async Clipboard API can sit unanswered indefinitely (e.g. an
// embedded or automated page with nobody to click it), so that path is
// capped with a short timeout and falls back to execCommand, which works
// off the same click without ever prompting.
function execCommandCopy(text) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.focus()
ta.select()
let ok = false
try {
ok = document.execCommand('copy')
} catch (e) {

Check warning on line 386 in frontend/src/api/wiring.ts

View workflow job for this annotation

GitHub Actions / frontend

'e' is defined but never used

Check warning on line 386 in frontend/src/api/wiring.ts

View workflow job for this annotation

GitHub Actions / frontend

'e' is defined but never used
ok = false
}
document.body.removeChild(ta)
return ok
}

async function copyText(text) {
try {
await Promise.race([
navigator.clipboard.writeText(text),
new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), 1200)),
])
return true
} catch (e) {
return execCommandCopy(text)
}
}

// The confirmation flashes on the button itself (like the API tab's "Copy"
// buttons), rather than the usual toast() - the toast lands on #canvas,
// which is hidden while the Welcome/Profiler/other tabs are the one shown,
// so a person copying a picker result before ever opening a network would
// never see it.
async function copySelectedOption(buttonId, selectId) {
const btn = document.getElementById(buttonId)
const sel = document.getElementById(selectId)
const opt = sel.options[sel.selectedIndex]
const original = btn.textContent
const flash = (text) => {
btn.textContent = text
setTimeout(() => {
btn.textContent = original
}, 1200)
}
// A freshly populated multi-row <select> isn't guaranteed to start with
// an option selected (unlike a plain dropdown), so clicking this before
// choosing one is a real click-through, not just a shouldn't-happen case.
if (!opt) return flash('Choose one first')
const ok = await copyText(opt.text)
flash(ok ? 'Copied' : "Couldn't copy")
}

// page wiring, run by main.ts in the original order
export function init() {
/* ---------- wiring ---------- */
Expand All @@ -374,12 +435,15 @@
const bind = (id, fn) => document.getElementById(id).addEventListener('click', fn)
bind('btnReactomeSearch', reactomeSearch)
bind('btnReactomeFetch', reactomeFetch)
bind('btnReactomeCopy', () => copySelectedOption('btnReactomeCopy', 'reactomePathway'))
bind('btnOmnipathFetch', omnipathFetch)
bind('btnNdexSearch', ndexSearch)
bind('btnNdexFetch', ndexFetch)
bind('btnNdexCopy', () => copySelectedOption('btnNdexCopy', 'ndexNetwork'))
bind('btnIntactFetch', intactFetch)
bind('btnGoModels', goLoadModels)
bind('btnGoModelFetch', goFetchModel)
bind('btnGoCopy', () => copySelectedOption('btnGoCopy', 'goModel'))
bind('btnGoAnnotate', goAnnotateView)
document.getElementById('goModelFilter').addEventListener('input', filterGoModels)
const sc = document.getElementById('intactScore')
Expand Down
96 changes: 78 additions & 18 deletions frontend/src/enrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1926,11 +1926,9 @@ export async function reactomeSearch() {
`${DB_URLS.reactome}/search/query?query=${encodeURIComponent(q)}&species=${encodeURIComponent(species)}&types=Pathway&cluster=true`
)
const entries = ((r && r.results) || []).flatMap((g) => g.entries || [])
entries
.slice(0, 40)
.forEach((e) =>
sel.add(new Option(`${stripTags(e.name)} (${e.stId || e.id})`, e.stId || e.id))
)
sortedByName(entries.slice(0, 40), (e) => stripTags(e.name)).forEach((e) =>
sel.add(new Option(`${stripTags(e.name)} (${e.stId || e.id})`, e.stId || e.id))
)
}
document.getElementById('reactomePickRow').hidden = !sel.options.length
dbStatus(
Expand Down Expand Up @@ -2374,11 +2372,21 @@ export async function ndexSearch() {
body: JSON.stringify({ searchString: q }),
contentType: 'application/json',
})
;((r && r.networks) || []).forEach((n) => {
// Some sources (e.g. WikiPathways) name networks "WP4284 - Cell Cycle":
// an id, then " - ", then the actual title. Shown and sorted by that
// title instead, with the id moved after it ("Cell Cycle (WP4284)"),
// matching the Reactome/GO-CAM "title (id)" pickers. Names with no
// such prefix (no digit in the leading token) are left as they are.
const networks = ((r && r.networks) || []).map((n) => {
const m = /^(\S*\d\S*) - (.+)$/.exec(String(n.name || ''))
return { ...n, ndexTitle: m ? m[2] : n.name, ndexId: m ? m[1] : null }
})
sortedByName(networks, (n) => n.ndexTitle).forEach((n) => {
const big = (n.nodeCount || 0) > MAX_NETWORK_NODES ? ' — larger than NORMA shows' : ''
const label = n.ndexId ? `${n.ndexTitle} (${n.ndexId})` : n.ndexTitle
sel.add(
new Option(
`${n.name} · ${(n.nodeCount || 0).toLocaleString()} nodes, ${(n.edgeCount || 0).toLocaleString()} edges · ${n.owner || ''}${big}`,
`${label} · ${(n.nodeCount || 0).toLocaleString()} nodes, ${(n.edgeCount || 0).toLocaleString()} edges · ${n.owner || ''}${big}`,
n.externalId
)
)
Expand Down Expand Up @@ -2712,18 +2720,55 @@ const RO_LABELS = {

const termOf = (x) => (x == null ? null : typeof x === 'string' ? x : x.term || x.id || null)

// The taxon/models listing gives "gocam": "http://model.geneontology.org/<id>"
// (a full URL, not a bare id); gocam-model/<id> 404s on anything but the bare
// id, so every candidate field is reduced to its last path segment here, once,
// rather than passing whatever the API happened to send straight through.
const bareGoId = (s) => (s == null ? null : String(s).replace(/^.*\//, ''))

export async function goLoadModels() {
await dbRun('go', async () => {
const taxon = document.getElementById('goTaxon').value
dbProgress('go', 'Listing GO-CAM models…')
const r = await dbFetch('go', `${DB_URLS.goapi}/taxon/${encodeURIComponent(taxon)}/models`)
const list = (Array.isArray(r) ? r : (r && (r.models || r.results)) || []).map((m) =>
typeof m === 'string'
? { id: m, title: m }
: { id: m.id || m.gocam || m.model_id, title: m.title || m.name || m.id || m.gocam }
)
const list = (Array.isArray(r) ? r : (r && (r.models || r.results)) || []).map((m) => {
if (typeof m === 'string') {
const id = bareGoId(m)
return { id, title: id }
}
const id = bareGoId(m.id || m.gocam || m.model_id)
return { id, title: m.title || m.name || id }
})
goState.models = list.filter((m) => m.id)
filterGoModels()
const shown = filterGoModels()
// The taxon listing has no titles (just ids), so the ones actually shown
// are looked up one model at a time and the option text updated in
// place; goState.models keeps them (title !== id marks "already known"),
// so re-filtering to the same models within this session won't re-fetch.
const toName = shown.filter((m) => m.title === m.id)
if (toName.length) {
dbProgress('go', `Reading model titles: 0 of ${toName.length}…`)
await dbMap(
'go',
toName,
6,
async (m) => {
try {
const d = await dbFetch(
'go',
`${DB_URLS.goapi}/gocam-model/${encodeURIComponent(m.id)}`
)
if (d && d.title) m.title = d.title
} catch {
// keep the id as the label
}
},
(done, total) => dbProgress('go', `Reading model titles: ${done} of ${total}…`)
)
// titles just came in: re-render so the list re-sorts by them instead
// of staying in the id order it was first shown in
renderGoOptions(shown)
}
dbStatus('go', [
{
level: goState.models.length ? 'ok' : 'warn',
Expand All @@ -2737,18 +2782,33 @@ export async function goLoadModels() {

const goState = { models: [] }

export function filterGoModels() {
const f = document.getElementById('goModelFilter').value.trim().toLowerCase()
// Before a title is known, m.title === m.id (see goLoadModels): show the
// bare id then, and "title (id)" once the real title comes in - the same
// "name (id)" shape as the Reactome/NDEx pickers.
const goOptionLabel = (m) => (m.title && m.title !== m.id ? `${m.title} (${m.id})` : m.id)

// Sorted by whatever each model's label is showing right now (title once
// known, the bare id until then), same as the Reactome/NDEx pickers.
function renderGoOptions(models) {
const sel = document.getElementById('goModel')
sel.innerHTML = ''
goState.models
sortedByName(models, (m) => m.title).forEach((m) => sel.add(new Option(goOptionLabel(m), m.id)))
}

export function filterGoModels() {
const f = document.getElementById('goModelFilter').value.trim().toLowerCase()
// Capped lower than Reactome/NDEx's 40: each not-yet-named model shown
// costs its own gocam-model request (see goLoadModels), so this bounds a
// single "List models"/filter to at most 60 of those round trips.
const shown = goState.models
.filter(
(m) =>
!f || String(m.title).toLowerCase().includes(f) || String(m.id).toLowerCase().includes(f)
)
.slice(0, 300)
.forEach((m) => sel.add(new Option(m.title, m.id)))
.slice(0, 60)
renderGoOptions(shown)
document.getElementById('goPickRow').hidden = !goState.models.length
return shown
}

export async function goFetchModel() {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/export/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,11 +502,11 @@ export function fr3dLayout(ids, edges, opts) {

let laidOut = 0
const parts = comps.map((m, c) => {
S.workRange = [laidOut / n, (laidOut + m.length) / n]
globalThis.__normaWorkRange = [laidOut / n, (laidOut + m.length) / n]
laidOut += m.length
return layoutComponent(m.length, compEdges[c])
})
S.workRange = [0, 1]
globalThis.__normaWorkRange = [0, 1]
// pack: the largest piece in the middle, the others on a shell around it
const centers = [[0, 0, 0]]
if (parts.length > 1) {
Expand Down
Binary file added frontend/src/layouts/controls.test.ts
Binary file not shown.
Loading
Loading