From 52642b7b44d8439f7398f5536a1d23d78036f7e6 Mon Sep 17 00:00:00 2001
From: Simon Malesys <simon.malesys@pasteur.fr>
Date: Mon, 28 Oct 2024 17:37:11 +0100
Subject: [PATCH] Add a search by motif

---
 src/client/components/AdvancedSearch.vue     | 31 ++++++----
 src/client/components/AntibodiesTable.vue    |  1 +
 src/client/components/MotifInput.vue         | 61 ++++++++++++++++++++
 src/client/components/SearchBar.vue          | 14 +++--
 src/client/globals.css                       |  1 +
 src/client/types.ts                          |  6 +-
 src/server/hooks/buildDBQuery.js             | 13 +++++
 src/server/hooks/parseRequest.js             |  5 ++
 src/server/routes/antibodiesCountRoute.js    |  7 +++
 src/server/routes/antibodiesDownloadRoute.js |  7 +++
 src/server/routes/antibodiesFindRoute.js     |  7 +++
 11 files changed, 136 insertions(+), 17 deletions(-)
 create mode 100644 src/client/components/MotifInput.vue

diff --git a/src/client/components/AdvancedSearch.vue b/src/client/components/AdvancedSearch.vue
index 2c752a14..23227419 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 6cb2ce93..497930b1 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 00000000..f3030de6
--- /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 ad8a350e..10c426c3 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 bf666502..b6c4501f 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 887b560d..ecb980cc 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 16335e1d..027f4d42 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 379c88ee..a73fc7ec 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 6c0463a2..5c254efd 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 d765e012..3632d34b 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 ee922873..0f6ec7a0 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',
-- 
GitLab