diff --git a/src/client/components/AdvancedSearch.vue b/src/client/components/AdvancedSearch.vue index 2c752a148199dec103d129b0b4280d34faf6f397..232274196222dfc301993fc9c7d2688109fc6afc 100644 --- a/src/client/components/AdvancedSearch.vue +++ b/src/client/components/AdvancedSearch.vue @@ -35,7 +35,14 @@ <h2>ADVANCED SEARCH</h2> </header> - <div class="options-group"> + <section> + <h3> + SEQUENCE MOTIF + </h3> + <MotifInput v-model="filters.motif"></MotifInput> + </section> + + <section> <h3> ORGANISM <button @@ -61,9 +68,9 @@ </AppCheckbox> </div> </div> - </div> + </section> - <div class="options-group"> + <section> <h3> SOURCES <button @@ -89,9 +96,9 @@ </AppCheckbox> </div> </div> - </div> + </section> - <div class="options-group"> + <section> <h3> HEAVY CHAIN GENE SEGMENTS <button @@ -117,9 +124,9 @@ </AppCheckbox> </div> </div> - </div> + </section> - <div class="options-group"> + <section> <h3> LIGHT CHAIN GENE SEGMENTS <button @@ -145,7 +152,7 @@ </AppCheckbox> </div> </div> - </div> + </section> <footer> <button @@ -165,6 +172,7 @@ import AppDialog from './AppDialog.vue' import { useStore } from '../store' import { type AdvancedFilters, type FilterChip } from '../types' import AppCheckbox from './AppCheckbox.vue'; +import MotifInput from './MotifInput.vue'; onMounted(() => { if (store.antibodiesSources.length === 0) { @@ -184,7 +192,8 @@ const filters: Ref<AdvancedFilters> = ref({ species: [], sources: [], heavySegments: [], - lightSegments: [] + lightSegments: [], + motif: "" }) const sourcesList = computed(() => store.antibodiesSources) const speciesList = computed(() => store.antibodiesSpecies) @@ -240,7 +249,7 @@ function updateFilters(): void { * @param filter - The name of the filter group * @param list - The list of values to select */ -function selectAll(filter: keyof AdvancedFilters, list: Readonly<Array<string>>): void { +function selectAll(filter: keyof Omit<AdvancedFilters, "motif">, list: Readonly<Array<string>>): void { if (filters.value[filter].length === list.length) { filters.value[filter] = [] } else { @@ -308,7 +317,7 @@ function removeFilter(filterChip: FilterChip): void { margin-top: 0; } -.options-group h3 { +.advanced-search-dialog section h3 { align-items: center; display: flex; gap: var(--spacing); diff --git a/src/client/components/AntibodiesTable.vue b/src/client/components/AntibodiesTable.vue index 6cb2ce93e7c3dac1997c371c18f631d233262081..497930b1422dc99aea1922ad48bde2a4a81d05b9 100644 --- a/src/client/components/AntibodiesTable.vue +++ b/src/client/components/AntibodiesTable.vue @@ -279,6 +279,7 @@ function download(): void { sources: request.value.sources, heavySegments: request.value.heavySegments, lightSegments: request.value.lightSegments, + motif: request.value.motif, format: downloadFormat.value } diff --git a/src/client/components/MotifInput.vue b/src/client/components/MotifInput.vue new file mode 100644 index 0000000000000000000000000000000000000000..f3030de68bfe2a9c762a8833522ac9b8f1108916 --- /dev/null +++ b/src/client/components/MotifInput.vue @@ -0,0 +1,61 @@ +<template> + <div class="motif"> + <input + id="motif-input" + v-model="model" + type="text" + placeholder="e.g., GLLF, ACKC.." + spellcheck="false" + pattern="[A-Z,]*"> + <button + type="button" + class="clear-input" + @click="clearInput()"> + CLEAR + </button> + </div> +</template> + +<script lang="ts" setup> +const model = defineModel<string>() + +function clearInput(): void { + model.value = '' +} +</script> + +<style scoped> +.motif { + display: flex; + flex-flow: row nowrap; + gap: var(--spacing); + padding-bottom: var(--spacing); +} + +#motif-input { + background: none; + border: none; + border-bottom: 1px var(--white) solid; + border-radius: var(--radius) var(--radius) 0 0; + color: var(--primary); + font-size: var(--spacing); + padding: var(--half-spacing); + transition: background-color 0.2s; + width: 100%; + + &:focus { + background-color: var(--primary-translucent); + outline: none; + border-bottom-color: var(--primary); + } + + &:not(:valid) { + color: var(--red); + border-bottom-color: var(--red); + } + + &:not(:valid):focus { + background-color: var(--red-translucent); + } +} +</style> diff --git a/src/client/components/SearchBar.vue b/src/client/components/SearchBar.vue index ad8a350efe76b96d4fd19272c30f8d53d3e6c13b..10c426c3fbba3abdf2c5faa56a8bf6d23b490ef8 100644 --- a/src/client/components/SearchBar.vue +++ b/src/client/components/SearchBar.vue @@ -41,7 +41,8 @@ const filters: Ref<AdvancedFilters> = ref({ species: [], sources: [], heavySegments: [], - lightSegments: [] + lightSegments: [], + motif: "" }) /** @@ -79,13 +80,18 @@ function fetchAntibodies (): void { store.request.lightSegments = filters.value.lightSegments.join(',') } else delete store.request.lightSegments + if (filters.value.motif.length > 0) { + store.request.motif = filters.value.motif + } else delete store.request.motif + store.fetchAntibodies(store.request) store.countAntibodies({ keywords: store.request.keywords, species: store.request.species, sources: store.request.sources, heavySegments: store.request.heavySegments, - lightSegments: store.request.lightSegments + lightSegments: store.request.lightSegments, + motif: store.request.motif }) } </script> @@ -104,7 +110,7 @@ function fetchAntibodies (): void { .search-bar { border-bottom: 1px var(--white) solid; display: flex; - gap: var(--spacing); + gap: var(--half-spacing); padding-bottom: var(--half-spacing); transition: border-bottom-color .5s; } @@ -126,7 +132,7 @@ function fetchAntibodies (): void { font-size: var(--spacing); padding: 0 var(--half-spacing); min-width: 0; - transition: background-color 0.1s; + transition: background-color 0.2s; &:focus { background-color: var(--primary-translucent); diff --git a/src/client/globals.css b/src/client/globals.css index bf666502b8e7c10dd73626b068e672de4372ef0c..b6c4501f9de9e99ed0a8799facc353a0387ea62d 100644 --- a/src/client/globals.css +++ b/src/client/globals.css @@ -11,6 +11,7 @@ --primary: hsl(252, 100%, 86%); --primary-translucent: hsl(252, 100%, 86%, 0.3); --red: hsl(0, 69%, 50%); + --red-translucent: hsl(0, 69%, 50%, 0.3); --purple: hsl(252, 70%, 48%); --black: hsl(0, 0%, 4%); --black-translucent: hsla(0, 0%, 4%, 0.5); diff --git a/src/client/types.ts b/src/client/types.ts index 887b560d6c990d0b832112776673081f2b9735cc..ecb980cca75a387edbf3eebc2ac27d6bae3e6eb9 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -47,7 +47,8 @@ interface APIFiltersParams { species?: string sources?: string heavySegments?: string, - lightSegments?: string + lightSegments?: string, + motif?: string } /** @@ -118,7 +119,8 @@ export interface AdvancedFilters { species: Array<Antibody['species']> sources: Array<FastaHeader['source']> heavySegments: Array<FastaHeader['vGeneSegment']> - lightSegments: Array<FastaHeader['vGeneSegment']> + lightSegments: Array<FastaHeader['vGeneSegment']>, + motif: string } /** diff --git a/src/server/hooks/buildDBQuery.js b/src/server/hooks/buildDBQuery.js index 16335e1d78eac91cfe092c3eeafffa83c607572e..027f4d42d53898a122c9df9b685ffe843fcafb9a 100644 --- a/src/server/hooks/buildDBQuery.js +++ b/src/server/hooks/buildDBQuery.js @@ -72,6 +72,19 @@ export default function buildDBQuery(request, reply, done) { }) } + if (request.query.motif) { + const $or = [] + + request.query.motif + .forEach(motif => { + // TODO: test if removing the 'i' options is faster + $or.push({ 'heavyChain.sequence': { $regex: motif.trim(), $options: 'i' } }) + $or.push({ 'ligthChain.sequence': { $regex: motif.trim(), $options: 'i' } }) + }) + + where.$and.push({ $or }) + } + // Empty $and operators raise an error in MongoDB // so where needs to remain empty as well. if (where.$and.length !== 0) request.where = where diff --git a/src/server/hooks/parseRequest.js b/src/server/hooks/parseRequest.js index 379c88ee7e73ace82c0d8e713cbf228936af6cf7..a73fc7ec6968c4cc30d57c05babb9d0a7c8d261b 100644 --- a/src/server/hooks/parseRequest.js +++ b/src/server/hooks/parseRequest.js @@ -20,6 +20,11 @@ * for the ones that are not simple strings. */ export default function parseRequest(request, reply, done) { + console.log(JSON.stringify(request.query, null, 2)) + if (request.query.motif) { + request.query.motif = request.query.motif.trim().split(',') + } + if (request.query.keywords) { request.query.keywords = request.query.keywords.trim().split(',') } diff --git a/src/server/routes/antibodiesCountRoute.js b/src/server/routes/antibodiesCountRoute.js index 6c0463a28409eb6e69aa5733c543404f469daa36..5c254efd8c39fd22e32b38bea208c2a1b6e8cba6 100644 --- a/src/server/routes/antibodiesCountRoute.js +++ b/src/server/routes/antibodiesCountRoute.js @@ -88,6 +88,13 @@ export default { items: { type: 'string', } + }, + motif: { + description: 'The motif to search for in the sequences', + type: 'array', + items: { + type: 'string', + } } } }, diff --git a/src/server/routes/antibodiesDownloadRoute.js b/src/server/routes/antibodiesDownloadRoute.js index d765e012a4c77e302d92dfd1dab3a683b36725a1..3632d34b2d2516cfd8f2f9debdfd2723bf009bb7 100644 --- a/src/server/routes/antibodiesDownloadRoute.js +++ b/src/server/routes/antibodiesDownloadRoute.js @@ -155,6 +155,13 @@ export default { items: { type: 'string', } + }, + motif: { + description: 'The motif to search for in the sequences', + type: 'array', + items: { + type: 'string', + } } } } diff --git a/src/server/routes/antibodiesFindRoute.js b/src/server/routes/antibodiesFindRoute.js index ee9228730e6cb5cd3f13e16fae944f23e21d1f6e..0f6ec7a04359eff0e3969ffb94d64ffb12a8e1ed 100644 --- a/src/server/routes/antibodiesFindRoute.js +++ b/src/server/routes/antibodiesFindRoute.js @@ -100,6 +100,13 @@ export default { type: 'string', } }, + motif: { + description: 'The motif to search for in the sequences', + type: 'array', + items: { + type: 'string', + } + }, limit: { description: 'The maximum number of antibodies to return. Used mainly for pagination.', type: 'integer',