diff --git a/angular.json b/angular.json index ad270c26..4cf5a629 100644 --- a/angular.json +++ b/angular.json @@ -356,8 +356,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "1.65MB", - "maximumError": "1.8MB" + "maximumWarning": "1.4MB", + "maximumError": "1.45MB" } ] }, @@ -369,8 +369,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "1.65MB", - "maximumError": "1.8MB" + "maximumWarning": "1.4MB", + "maximumError": "1.45MB" } ] }, diff --git a/e2e/citation-dialog.spec.ts b/e2e/citation-dialog.spec.ts new file mode 100644 index 00000000..aa766154 --- /dev/null +++ b/e2e/citation-dialog.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from './support/backend'; + +/** + * "Cite us" opens the citation for the pathway on screen. + * + * The dialog and its component are fetched when it is first opened rather than + * with the page (#356), so the first click has something to wait for. Nothing + * tested the dialog before; writing this found the button had no name. + */ +test('Cite us opens the citation for the pathway on screen, and closes', async ({ page }) => { + const thrown: string[] = []; + page.on('pageerror', (error) => thrown.push(String(error))); + // The request carries today's date (?dateAccessed=...), so no recording can + // match it; answered here in the service's own shape. + await page.route('**/ContentService/citation/pathway/R-HSA-69620?*', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + pathwayCitation: + "Yen, T.J. ,Khanna, K.K. ,Hoffmann, I. ,Walworth, N. & O'Donnell, M. (2005). Cell Cycle Checkpoints. Reactome, 97, https://reactome.org/content/detail/R-HSA-69620", + imageCitation: + 'Image Citation for Cell Cycle Checkpoints. Reactome, 97, https://reactome.org/content/detail/R-HSA-69620', + }), + }) + ); + await page.goto('/PathwayBrowser/R-HSA-69620'); + // By its name: it had none, only a tooltip, so a screen reader announced an + // unnamed button. + await page.getByRole('button', { name: 'Cite us' }).click({ timeout: 60_000 }); + const dialog = page.locator('cr-citation'); + await expect(dialog.getByRole('heading', { name: 'Cite Us!' })).toBeVisible({ timeout: 30_000 }); + await expect(dialog.getByRole('heading', { name: 'Pathway' })).toBeVisible({ timeout: 30_000 }); + await expect(dialog.locator('mat-dialog-content')).toContainText( + '(2005). Cell Cycle Checkpoints' + ); + await dialog.locator('.close-button').click(); + await expect(dialog).toHaveCount(0); + expect(thrown).toEqual([]); +}); + +test("the site's Cite Us button opens the citation from any page", async ({ page }) => { + const thrown: string[] = []; + page.on('pageerror', (error) => thrown.push(String(error))); + await page.goto('/'); + await page.getByRole('button', { name: 'Cite Us', exact: true }).click({ timeout: 60_000 }); + const dialog = page.locator('cr-citation'); + await expect(dialog.getByRole('heading', { name: 'Cite Us!' })).toBeVisible({ timeout: 30_000 }); + await expect(dialog.locator('mat-dialog-content')).not.toBeEmpty({ timeout: 30_000 }); + expect(thrown).toEqual([]); +}); + +test('two quick clicks open one citation, not two', async ({ page }) => { + // The dialog's code loads on the first click, so a second click can land + // while it is still on its way; each used to open a dialog of its own. + await page.goto('/'); + const button = page.getByRole('button', { name: 'Cite Us', exact: true }); + await button.click({ timeout: 60_000 }); + await button.click({ force: true }); + const dialog = page.locator('cr-citation'); + await expect(dialog.getByRole('heading', { name: 'Cite Us!' })).toBeVisible({ timeout: 30_000 }); + // Long enough for a second dialog to have opened, had one been coming. + await page.waitForTimeout(2000); + await expect(dialog).toHaveCount(1); +}); diff --git a/e2e/har/4acadeec725de92e73791996e1f975866d71c861.txt b/e2e/har/4acadeec725de92e73791996e1f975866d71c861.txt new file mode 100644 index 00000000..a1db2b5a --- /dev/null +++ b/e2e/har/4acadeec725de92e73791996e1f975866d71c861.txt @@ -0,0 +1 @@ +Fabregat A, Sidiropoulos K, Viteri G, Marin-Garcia P, Ping P, Stein L, D'Eustachio P, Hermjakob H. Reactome diagram viewer: data structures and strategies to boost performance. Bioinformatics (Oxford, England). 2018 Apr;34(7) 1208-1214. doi: 10.1093/bioinformatics/btx752. PubMed PMID: 29186351. PubMed Central PMCID: PMC6030826. \ No newline at end of file diff --git a/e2e/har/citation-dialog--cite-us-opens-the-citation-for-the-pathway-on-screen-and-clo-96a52b.har b/e2e/har/citation-dialog--cite-us-opens-the-citation-for-the-pathway-on-screen-and-clo-96a52b.har new file mode 100644 index 00000000..766a4cd1 --- /dev/null +++ b/e2e/har/citation-dialog--cite-us-opens-the-citation-for-the-pathway-on-screen-and-clo-96a52b.har @@ -0,0 +1 @@ +{"log":{"version":"1.2","entries":[{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/database/version"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"2"},{"name":"content-type","value":"text/plain;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:56:58 GMT"}],"content":{"mimeType":"text/plain;charset=UTF-8","_file":"812ed4562d3211363a7b813aa9cd2cf042b63bb2.txt"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/species/main"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"3237"},{"name":"content-type","value":"application/json; charset=utf-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:56:58 GMT"},{"name":"etag","value":"W/\"ca5-sa7k1xX74ycTbey3jmBLCrhGTO0\""}],"content":{"mimeType":"application/json; charset=utf-8","_file":"b1aee4d715fbe327136decb78e604b0ab8464ced.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/query/enhanced/v2/R-HSA-69620?fetchIncomingRelationships=true&summariseReferenceEntity=false&includeDisease=true&includeRef=true&view=nested-aggregated"},"response":{"status":200,"headers":[{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:56:58 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"e71f7705e50fdd572ddc0cf27a5b6ed24c562e03.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/event/R-HSA-69620/ancestors?includeRef=true&view=nested-aggregated"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"696"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:56:59 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"77b1f4b5f0865f10a4a061fe36168fd1f62e604b.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/query/enhanced/v2/R-HSA-69620?fetchIncomingRelationships=true&summariseReferenceEntity=true&includeDisease=true&includeRef=true&view=nested-aggregated"},"response":{"status":200,"headers":[{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:56:58 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"e71f7705e50fdd572ddc0cf27a5b6ed24c562e03.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ExperimentDigester/experiments/summaries"},"response":{"status":200,"headers":[{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Origin"},{"name":"connection","value":"close"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:56:58 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"5e25288a19ddb81e5e80ee5d8aa56f19cd68dd6e.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/pathways/top/9606"},"response":{"status":200,"headers":[{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:56:58 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"28a2743b73a578fbfc87d6ec212f2dababf479e3.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/interactors/psicquic/resources/"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"6674"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:56:58 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"0e84a258a3b413badb1205d66079ecb7ea99bbd0.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/query/enhanced/v2/R-HSA-69620?includeRef=true&view=nested-aggregated&summariseReferenceEntity=false&includeDisease=true"},"response":{"status":200,"headers":[{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:56:58 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"e71f7705e50fdd572ddc0cf27a5b6ed24c562e03.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/detail/R-HSA-69620/locationsInPWB"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"284"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:56:58 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"f54e01541057b3966e2f3122f73577bb12bb7d64.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/event/R-HSA-69620/ancestors"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"696"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:56:59 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"77b1f4b5f0865f10a4a061fe36168fd1f62e604b.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/query/enhanced/v2/R-HSA-1640170?includeRef=true&view=nested-aggregated&summariseReferenceEntity=false&includeDisease=true"},"response":{"status":200,"headers":[{"name":"Transfer-Encoding","value":"chunked"},{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-type","value":"application/json;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 11:57:01 GMT"}],"content":{"mimeType":"application/json;charset=UTF-8","_file":"a8afa20987171d392bfd782043fe5594e9842a18.json"}}}]}} \ No newline at end of file diff --git a/e2e/har/citation-dialog--the-site-s-cite-us-button-opens-the-citation-from-any-page-50d4ad.har b/e2e/har/citation-dialog--the-site-s-cite-us-button-opens-the-citation-from-any-page-50d4ad.har new file mode 100644 index 00000000..cf9d07e8 --- /dev/null +++ b/e2e/har/citation-dialog--the-site-s-cite-us-button-opens-the-citation-from-any-page-50d4ad.har @@ -0,0 +1 @@ +{"log":{"version":"1.2","entries":[{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/species/main"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"3237"},{"name":"content-type","value":"application/json; charset=utf-8"},{"name":"date","value":"Tue, 13 Oct 2026 12:08:04 GMT"},{"name":"etag","value":"W/\"ca5-sa7k1xX74ycTbey3jmBLCrhGTO0\""}],"content":{"mimeType":"application/json; charset=utf-8","_file":"b1aee4d715fbe327136decb78e604b0ab8464ced.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/database/version"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"2"},{"name":"content-type","value":"text/plain;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 12:08:04 GMT"}],"content":{"mimeType":"text/plain;charset=UTF-8","_file":"812ed4562d3211363a7b813aa9cd2cf042b63bb2.txt"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/citation/static/29186351"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"330"},{"name":"content-type","value":"text/plain;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 12:08:06 GMT"}],"content":{"mimeType":"text/plain;charset=UTF-8","_file":"4acadeec725de92e73791996e1f975866d71c861.txt"}}}]}} \ No newline at end of file diff --git a/e2e/har/citation-dialog--two-quick-clicks-open-one-citation-not-two-f55986.har b/e2e/har/citation-dialog--two-quick-clicks-open-one-citation-not-two-f55986.har new file mode 100644 index 00000000..24f60dc2 --- /dev/null +++ b/e2e/har/citation-dialog--two-quick-clicks-open-one-citation-not-two-f55986.har @@ -0,0 +1 @@ +{"log":{"version":"1.2","entries":[{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/species/main"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"3237"},{"name":"content-type","value":"application/json; charset=utf-8"},{"name":"date","value":"Tue, 13 Oct 2026 12:18:24 GMT"},{"name":"etag","value":"W/\"ca5-sa7k1xX74ycTbey3jmBLCrhGTO0\""}],"content":{"mimeType":"application/json; charset=utf-8","_file":"b1aee4d715fbe327136decb78e604b0ab8464ced.json"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/data/database/version"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"2"},{"name":"content-type","value":"text/plain;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 12:18:24 GMT"}],"content":{"mimeType":"text/plain;charset=UTF-8","_file":"812ed4562d3211363a7b813aa9cd2cf042b63bb2.txt"}}},{"request":{"method":"GET","url":"http://localhost:4330/ContentService/citation/static/29186351"},"response":{"status":200,"headers":[{"name":"Vary","value":"Origin"},{"name":"access-control-allow-origin","value":"*"},{"name":"connection","value":"close"},{"name":"content-length","value":"330"},{"name":"content-type","value":"text/plain;charset=UTF-8"},{"name":"date","value":"Tue, 13 Oct 2026 12:18:24 GMT"}],"content":{"mimeType":"text/plain;charset=UTF-8","_file":"4acadeec725de92e73791996e1f975866d71c861.txt"}}}]}} \ No newline at end of file diff --git a/projects/pathway-browser/src/app/services/citation.service.ts b/projects/pathway-browser/src/app/services/citation.service.ts index 566d6094..9c0e8b18 100644 --- a/projects/pathway-browser/src/app/services/citation.service.ts +++ b/projects/pathway-browser/src/app/services/citation.service.ts @@ -1,4 +1,4 @@ -import { computed, inject, Injectable, Signal, signal } from '@angular/core'; +import { computed, inject, Injectable, Injector, Signal, signal } from '@angular/core'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { environment } from '../../environments/environment'; @@ -6,8 +6,6 @@ import { isPathway } from './utils'; import { DataStateService } from './data-state.service'; import { AnalysisService } from './analysis.service'; import { rxResource } from '@angular/core/rxjs-interop'; -import { CitationComponent } from '../citation/citation.component'; -import { MatDialog } from '@angular/material/dialog'; interface PathwayCitation { imageCitation: string; @@ -38,6 +36,9 @@ export enum ExportFormat { TXT = 'txt', } +/** The citation dialog's id, so a second open finds the first rather than stacking. */ +const CITATION_DIALOG = 'citation'; + @Injectable({ providedIn: 'root', }) @@ -46,7 +47,10 @@ export class CitationService { private dataState = inject(DataStateService); private analysis = inject(AnalysisService); - readonly dialog = inject(MatDialog); + // The dialog and its component are fetched when it is first opened, not with + // the page: the embeddable diagram provides this service but never shows the + // dialog, and with them it carried Material's dialog code for nothing (#356). + private readonly injector = inject(Injector); readonly currentCitationId = signal(undefined); readonly currentCitationExportURLS = computed(() => @@ -96,9 +100,30 @@ export class CitationService { : this.http.get(`${pathwayUrl}`); } - openDialog() { + async openDialog(): Promise { + // One dialog at a time. The first open now waits while its code loads, and a + // reader who clicks again in that moment would otherwise get two. + if (this.opening) return; + this.opening = true; + try { + await this.showDialog(); + } finally { + this.opening = false; + } + } + + private opening = false; + + private async showDialog(): Promise { this.currentCitationId.set(this.updatedCitationId()); - const dialogRef = this.dialog.open(CitationComponent, { + const [{ MatDialog }, { CitationComponent }] = await Promise.all([ + import('@angular/material/dialog'), + import('../citation/citation.component'), + ]); + const dialog = this.injector.get(MatDialog); + if (dialog.getDialogById(CITATION_DIALOG)) return; + dialog.open(CitationComponent, { + id: CITATION_DIALOG, data: { content: this.citationData.value, id: this.currentCitationId, @@ -107,7 +132,6 @@ export class CitationService { enterAnimationDuration: '450ms', exitAnimationDuration: '450ms', }); - dialogRef.afterClosed(); } getExportUrls(id: string) { diff --git a/projects/pathway-browser/src/app/viewport/viewport.component.html b/projects/pathway-browser/src/app/viewport/viewport.component.html index ca2c241d..3c270cb4 100644 --- a/projects/pathway-browser/src/app/viewport/viewport.component.html +++ b/projects/pathway-browser/src/app/viewport/viewport.component.html @@ -336,7 +336,13 @@ [matTooltip]="dark.isDark() ? 'Switch to light mode' : 'Switch to dark mode'" id="dark-toggle" > -