From 029186a1e6c906ea0226370d631cd2f54acf463d Mon Sep 17 00:00:00 2001 From: Vincent Rubinetti Date: Thu, 2 Nov 2023 18:04:24 -0400 Subject: [PATCH] Make autocomplete go directly to entity (#458) Closes #456 Closes #451 Closes #445 Closes #439 - "dummy" -> "fake" - change e2e search tests to match new autocomplete behavior - rearrange phenogrid response - refactor autocomplete component to accommodate entity autocomplete search - fix input component event bug - change phenogrid tooltip content - tweak node history to record node visits instead of searches - remove overview about page (since it's out of date). see #436 - fix phenogrid infinite resize loop bug - refactor router scrollTo logic again, and fix minor bugs - refactor explore search tab to accommodate auto complete entity search - fix minor useQuery composable bug - remove scroll restoration behavior - fix small tab title has undefined while node name loads --- frontend/e2e/phenotype-explorer.test.ts | 4 +- frontend/e2e/search.test.ts | 56 ++++----- frontend/src/api/model.ts | 6 +- frontend/src/api/phenotype-explorer.ts | 12 +- frontend/src/api/search.ts | 23 +--- .../src/components/AppSelectAutocomplete.vue | 45 ++++--- frontend/src/components/AppTable.vue | 2 +- frontend/src/components/AppTextbox.vue | 5 +- frontend/src/components/TheHeader.vue | 4 +- frontend/src/components/ThePhenogrid.vue | 58 +++++---- frontend/src/global/history.ts | 19 ++- frontend/src/global/icons.ts | 2 +- frontend/src/pages/PageTestbed.vue | 6 +- frontend/src/pages/about/PageAbout.vue | 6 - frontend/src/pages/explore/PagePhenogrid.vue | 68 +++++----- .../pages/explore/TabPhenotypeExplorer.vue | 5 +- frontend/src/pages/explore/TabSearch.vue | 119 ++++++++++-------- frontend/src/pages/node/PageNode.vue | 27 ++-- .../pages/node/SectionAssociationDetails.vue | 5 +- .../src/pages/node/SectionAssociations.vue | 30 +---- frontend/src/router/index.ts | 66 ++++------ frontend/src/util/composables.ts | 4 +- frontend/unit/AppTextbox.test.ts | 2 +- frontend/unit/composables.test.ts | 10 +- 24 files changed, 272 insertions(+), 312 deletions(-) diff --git a/frontend/e2e/phenotype-explorer.test.ts b/frontend/e2e/phenotype-explorer.test.ts index 4b1128926..cf0221b66 100644 --- a/frontend/e2e/phenotype-explorer.test.ts +++ b/frontend/e2e/phenotype-explorer.test.ts @@ -69,7 +69,7 @@ test("Phenotype set vs gene/disease works", async ({ page }) => { .first() .click(); - /** paste specific dummy phenotypes */ + /** paste specific fake phenotypes */ await paste(page.locator("input"), "HP:0004970,HP:0004933,HP:0004927"); /** run analysis, and look for expected results */ @@ -87,7 +87,7 @@ test("Phenotype set vs phenotype set works", async ({ page }) => { await page.goto("/explore#phenotype-explorer"); - /** paste specific dummy phenotypes */ + /** paste specific fake phenotypes */ await paste( page.locator("input").first(), "HP:0004970,HP:0004933,HP:0004927", diff --git a/frontend/e2e/search.test.ts b/frontend/e2e/search.test.ts index 0802f2638..8fdeb7282 100644 --- a/frontend/e2e/search.test.ts +++ b/frontend/e2e/search.test.ts @@ -21,61 +21,59 @@ test("Redirects to explore page from home page", async ({ page }) => { test("Recent/frequent results show", async ({ page }) => { await page.goto("/explore"); - /** dummy searches */ - const searches = [ - "abc def", - "123", - "123", - "abc def", - "123", - "123", - "abc def", + const nodes = [ + "MONDO:0007523", + "HP:0003179", + "HP:0003179", + "MONDO:0007523", + "HP:0003179", + "HP:0003179", + "MONDO:0007523", + "HP:0100775", ]; - /** go through dummy searches */ - for (const search of searches) { - await page.locator("input").fill(search); - /** dispatch textbox change event which triggers search and records it */ - await page.locator("input").dispatchEvent("change"); - /** wait for results */ - await expect(page.locator("p.description").first()).toBeVisible(); - await page.locator(".textbox button").click(); + for (const node of nodes) { + await page.goto("/" + node); + await expect(page.locator("#overview")).toBeVisible(); } - /** go to node page, which should also get added to search history */ - await page.goto("/MONDO:12345"); - /** wait for page to load */ - await expect(page.locator("#overview")).toBeVisible(); await page.goto("/explore"); - - /** focus search box to show list of results */ await page.locator("input").focus(); + const options = page.locator("[role='option']"); /** recent */ - const options = page.locator("[role='option']"); await expect( options .nth(0) - .getByText(/muscular dystrophy/i) + .getByText(/Dural ectasia/i) .first(), ).toBeVisible(); await expect( options .nth(1) - .getByText(/abc def/i) + .getByText(/Ehlers-Danlos syndrome, hypermobility/i) + .first(), + ).toBeVisible(); + await expect( + options + .nth(2) + .getByText(/Protrusio acetabuli/i) .first(), ).toBeVisible(); - await expect(options.nth(2).getByText(/123/).first()).toBeVisible(); /** frequent */ await expect( - page.locator("[role='option']").nth(3).getByText(/123/).first(), + page + .locator("[role='option']") + .nth(3) + .getByText(/Protrusio acetabuli/i) + .first(), ).toBeVisible(); await expect( page .locator("[role='option']") .nth(4) - .getByText(/abc def/) + .getByText(/Ehlers-Danlos syndrome, hypermobility/i) .first(), ).toBeVisible(); }); diff --git a/frontend/src/api/model.ts b/frontend/src/api/model.ts index bbdbc59d1..34b656aeb 100644 --- a/frontend/src/api/model.ts +++ b/frontend/src/api/model.ts @@ -502,12 +502,12 @@ export interface TermPairwiseSimilarity extends PairwiseSimilarity { /** The IC of the object */ ancestor_information_content?: string, /** The number of concepts in the intersection divided by the number in the union */ - jaccard_similarity?: string, + jaccard_similarity?: number, /** the dot product of two node embeddings divided by the product of their lengths */ cosine_similarity?: number, - dice_similarity?: string, + dice_similarity?: number, /** the geometric mean of the jaccard similarity and the information content */ - phenodigm_score?: string, + phenodigm_score?: number, }; /** * A simple pairwise similarity between two sets of concepts/terms diff --git a/frontend/src/api/phenotype-explorer.ts b/frontend/src/api/phenotype-explorer.ts index 54159ecd7..cffdf02c8 100644 --- a/frontend/src/api/phenotype-explorer.ts +++ b/frontend/src/api/phenotype-explorer.ts @@ -139,8 +139,13 @@ export const compareSetToSet = async ( [key: string]: { score: number; strength: number; - simInfo: Partial; - }; + } & Pick< + TermPairwiseSimilarity, + | "ancestor_id" + | "ancestor_label" + | "jaccard_similarity" + | "phenodigm_score" + >; } = {}; /** get subject matches */ @@ -162,8 +167,9 @@ export const compareSetToSet = async ( cells[col.id + row.id] = { score: match?.score || 0, strength: 0, - simInfo: pick(match?.similarity, [ + ...pick(match?.similarity, [ "ancestor_id", + "ancestor_label", "jaccard_similarity", "phenodigm_score", ]), diff --git a/frontend/src/api/search.ts b/frontend/src/api/search.ts index 0da68fdb0..70b0523c8 100644 --- a/frontend/src/api/search.ts +++ b/frontend/src/api/search.ts @@ -1,5 +1,4 @@ -import { groupBy, uniq } from "lodash"; -import type { SearchResult, SearchResults } from "@/api/model"; +import type { SearchResults } from "@/api/model"; import { apiUrl, request } from "./index"; export type Filters = { [key: string]: string[] }; @@ -21,29 +20,13 @@ export const getSearch = async ( return response; }; -type DedupedSearchResults = Omit & { - items: (SearchResult & { dupes: string[] })[]; -}; - export const getAutocomplete = async (q: string) => { const url = `${apiUrl}/autocomplete`; const response = await request(url, { q }); - const transformedResponse: DedupedSearchResults = { + const transformedResponse = { ...response, - items: Object.values( - /** consolidate items */ - groupBy( - response.items, - /** by name, case insensitively */ - (item) => item.name.toLowerCase(), - ), - ).map((dupes) => ({ - ...dupes[0], - /** keep list of duplicated names */ - /** de-dupe this list case sensitively */ - dupes: uniq(dupes.map((dupe) => dupe.name)), - })), + items: response.items.slice(0, 20), }; return transformedResponse; diff --git a/frontend/src/components/AppSelectAutocomplete.vue b/frontend/src/components/AppSelectAutocomplete.vue index d662d64ae..3036173bb 100644 --- a/frontend/src/components/AppSelectAutocomplete.vue +++ b/frontend/src/components/AppSelectAutocomplete.vue @@ -62,11 +62,14 @@ :id="`option-${id}-${index}`" :key="index" v-tooltip="option.tooltip" - :class="['option', { highlighted: index === highlighted }]" + :class="[ + 'option', + { highlighted: index === highlighted, special: option.special }, + ]" role="option" :aria-selected="true" tabindex="0" - @click.prevent="() => select(option.label)" + @click.prevent="() => select(option)" @mousedown.prevent="" @focusin="() => null" @keydown="() => null" @@ -83,9 +86,6 @@ - - -
{{ description }}
@@ -99,12 +99,16 @@ export type Option = { icon?: string; /** display label */ label: string; + /** unique id */ + id?: string; /** highlighting html */ highlight?: string; /** info col */ info?: string; /** tooltip on hover */ tooltip?: string; + /** whether option is "special" (gets styled differently) */ + special?: boolean; }; @@ -136,9 +140,9 @@ type Emits = { /** when input focused */ focus: []; /** when input value change "submitted"/"committed" by user */ - change: [string]; + change: [string | Option, string]; /** when user wants to delete an entry */ - delete: [string]; + delete: [Option]; }; const emit = defineEmits(); @@ -182,9 +186,13 @@ async function onDebounce() { await runGetResults(); } +/** ignore next child input box change event */ +let ignoreChange = false; + /** when user "commits" change to value, e.g. pressing enter, de-focusing, etc */ -function onChange(value: string) { - select(value); +async function onChange(value: string) { + if (!ignoreChange) select(value); + ignoreChange = false; } /** when user presses key in input */ @@ -210,13 +218,12 @@ async function onKeydown(event: KeyboardEvent) { /** enter key to select highlighted result */ if (event.key === "Enter" && highlighted.value >= 0) { - event.stopPropagation(); - select(results.value[highlighted.value].label); + select(results.value[highlighted.value]); } /** delete key to delete the highlighted result */ if (event.key === "Delete" && event.shiftKey) { - emit("delete", results.value[highlighted.value].label); + emit("delete", results.value[highlighted.value]); await runGetResults(); } @@ -225,9 +232,11 @@ async function onKeydown(event: KeyboardEvent) { } /** select an option */ -async function select(value: string) { - search.value = value; - emit("change", value); +async function select(value: string | Option) { + /** ignore next child input box change event triggered by enter press */ + ignoreChange = true; + emit("change", value, search.value); + search.value = typeof value === "string" ? value : value.label; close(); } @@ -345,9 +354,7 @@ watch(highlighted, () => { color: $gray; } -.description { - margin-top: 10px; - color: $dark-gray; - font-size: 0.9rem; +.special { + font-weight: 500; } diff --git a/frontend/src/components/AppTable.vue b/frontend/src/components/AppTable.vue index 78ba2092d..5645cccaf 100644 --- a/frontend/src/components/AppTable.vue +++ b/frontend/src/components/AppTable.vue @@ -468,7 +468,7 @@ watch( } &.divider { - width: 2px; + width: 2px !important; margin: 0 auto; padding: 0; background: $light-gray; diff --git a/frontend/src/components/AppTextbox.vue b/frontend/src/components/AppTextbox.vue index ac1ec0fe1..05147d129 100644 --- a/frontend/src/components/AppTextbox.vue +++ b/frontend/src/components/AppTextbox.vue @@ -80,7 +80,7 @@ type Emits = { blur: []; }; -const emit = defineEmits(); +defineEmits(); /** element reference */ const textbox = ref(); @@ -91,7 +91,7 @@ const input = ref(); function clear() { input.value.input.value = ""; input.value.input.dispatchEvent(new Event("input")); - emit("change", ""); + input.value.input.dispatchEvent(new Event("change")); } /** allow parent to access ref */ @@ -141,7 +141,6 @@ $height: 40px; justify-content: center; width: $height; height: $height; - color: $gray; } .input { diff --git a/frontend/src/components/TheHeader.vue b/frontend/src/components/TheHeader.vue index c9943f111..02a2aa4bb 100644 --- a/frontend/src/components/TheHeader.vue +++ b/frontend/src/components/TheHeader.vue @@ -242,8 +242,8 @@ $wrap: 900px; .nav { display: flex; align-items: center; - justify-content: center; - max-width: 100%; + justify-content: flex-end; + width: 100%; padding: 15px; gap: 10px; } diff --git a/frontend/src/components/ThePhenogrid.vue b/frontend/src/components/ThePhenogrid.vue index 8d8025628..fd0912ad2 100644 --- a/frontend/src/components/ThePhenogrid.vue +++ b/frontend/src/components/ThePhenogrid.vue @@ -85,21 +85,28 @@ :node="{ id: row.id, name: row.label }" :absolute="true" /> - Score + Ancestor + + Ancestor IC {{ - data.cells[col.id + row.id].score.toFixed(2) + data.cells[col.id + row.id].score.toFixed(3) + }} + Phenodigm + {{ + data.cells[col.id + row.id].phenodigm_score?.toFixed(3) + }} + Jaccard + {{ + data.cells[col.id + row.id].jaccard_similarity?.toFixed( + 3, + ) }} - @@ -146,7 +153,7 @@ :options="sortMethods" /> - + import { ref } from "vue"; -import { sortBy, startCase } from "lodash"; +import { sortBy } from "lodash"; import { hideAll } from "tippy.js"; import type { TermInfo } from "@/api/model"; import { type SetToSet } from "@/api/phenotype-explorer"; @@ -190,17 +197,21 @@ function hoverCell(colIndex: number, rowIndex: number, unset = false) { else hovered.value = { col: colIndex, row: rowIndex }; } -const flex = ref<{ element: HTMLTableElement }>(); +const flex = ref<{ element: HTMLElement }>(); const scroll = ref(); +/** set container to be full size (of contents) */ +function setFullsize(full: boolean) { + if (!flex.value?.element || !scroll.value) return; + scroll.value.classList[full ? "add" : "remove"]("full-size"); + flex.value.element.classList[full ? "add" : "remove"]("full-size"); +} + /** download grid as png */ async function download() { if (!flex.value?.element || !scroll.value) return; - /** make full size */ - scroll.value.classList.add("saving"); - flex.value.element.classList.add("saving"); - + setFullsize(true); hideAll(); /** wait for dom to update */ @@ -216,9 +227,7 @@ async function download() { snackbar("Error saving image"); } - /** reset size */ - scroll.value.classList.remove("saving"); - flex.value.element.classList.remove("saving"); + setFullsize(false); } /** options for sorting */ @@ -251,12 +260,11 @@ function copy() { - diff --git a/frontend/src/pages/explore/TabPhenotypeExplorer.vue b/frontend/src/pages/explore/TabPhenotypeExplorer.vue index 70f8d72d8..068d046cb 100644 --- a/frontend/src/pages/explore/TabPhenotypeExplorer.vue +++ b/frontend/src/pages/explore/TabPhenotypeExplorer.vue @@ -135,9 +135,8 @@ import type { Option, Options } from "@/components/AppSelectTags.vue"; import AppSelectTags from "@/components/AppSelectTags.vue"; import ThePhenogrid from "@/components/ThePhenogrid.vue"; import { snackbar } from "@/components/TheSnackbar.vue"; -import { scrollToElement } from "@/router"; +import { scrollTo } from "@/router"; import { useQuery } from "@/util/composables"; -import { waitFor } from "@/util/dom"; import { parse } from "@/util/object"; import examples from "./phenotype-explorer.json"; @@ -235,7 +234,7 @@ const { /** scroll results into view */ async function scrollToResults() { - scrollToElement(await waitFor("#results")); + scrollTo("#results"); } /** when multi select component runs spread options function */ diff --git a/frontend/src/pages/explore/TabSearch.vue b/frontend/src/pages/explore/TabSearch.vue index 83222b196..8339b78ea 100644 --- a/frontend/src/pages/explore/TabSearch.vue +++ b/frontend/src/pages/explore/TabSearch.vue @@ -108,24 +108,27 @@ @@ -429,8 +445,7 @@ watch(from, () => runGetSearch(false)); } .header-box { - width: 300px; - max-width: 100%; + width: 100%; } .header-box :deep(input) { @@ -444,7 +459,7 @@ watch(from, () => runGetSearch(false)); } .header-box :deep(.icon) { - color: currentColor; + color: currentColor !important; } diff --git a/frontend/src/pages/node/PageNode.vue b/frontend/src/pages/node/PageNode.vue index 350afcd13..e7740b430 100644 --- a/frontend/src/pages/node/PageNode.vue +++ b/frontend/src/pages/node/PageNode.vue @@ -36,14 +36,13 @@