diff --git a/src/client/api.ts b/src/client/api.ts index c66b9ef9fa78924efb9f1d6cbb4ad6c8232b169a..ca2db43e8496af498b7901b333ec183c2f757987 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -7,7 +7,6 @@ import type { APIDownloadResponse, APIFetchParams, APIFetchResponse, - APISegmentsResponse, APIUserMadeArchiveResponse, Antibody, Statistics @@ -40,15 +39,6 @@ export default Object.freeze({ downloadAntibodies: async (params: APIDownloadParams): Promise<AxiosResponse<APIDownloadResponse>> => { return await base.get('/api/antibodies/download', { params }) }, - getAntibodiesSources: async (): Promise<AxiosResponse<string[]>> => { - return await base.get('/api/antibodies/sources') - }, - getAntibodiesSpecies: async (): Promise<AxiosResponse<string[]>> => { - return await base.get('/api/antibodies/species') - }, - getAntibodiesSegments: async (): Promise<AxiosResponse<APISegmentsResponse>> => { - return await base.get('/api/antibodies/v-gene-segments') - }, getStatistics: async (): Promise<AxiosResponse<Statistics>> => { return await base.get('/api/statistics') }, diff --git a/src/client/components/AdvancedSearch.vue b/src/client/components/AdvancedSearch.vue index 21a3c6a2ad4e00da2c819ae687b7a2381fd85ff7..c4e204e51dfbd1c3c43537010218025f76a555e5 100644 --- a/src/client/components/AdvancedSearch.vue +++ b/src/client/components/AdvancedSearch.vue @@ -168,6 +168,7 @@ <button class="search-button" type="button" + :disabled="isFetchingAntibodies" @click="closeAdvancedSearch" > SEARCH @@ -184,20 +185,13 @@ import AppCheckbox from './AppCheckbox.vue' import AppDialog from './AppDialog.vue' import MotifInput from './MotifInput.vue' -// onMounted(() => { -// if (store.antibodiesSources.length === 0) { -// store.getAntibodiesSources() -// store.getAntibodiesSpecies() -// store.getAntibodiesSegments() -// } -// }) - const emit = defineEmits<{ filtersUpdate: [filters: AdvancedFilters] }>() const store = useStore() const request = computed(() => store.request) +const isFetchingAntibodies = computed(() => { return store.isFetchingAntibodies }) const advancedSearchVisible = ref(false) const filters = ref(request.value.advancedFilters()) const motifIsValid = ref(true) diff --git a/src/client/components/AntibodiesTable.vue b/src/client/components/AntibodiesTable.vue index efded9edb954a0ad0a93b30f119fa25d67a2b812..2d02d4fcf853cc952f4220c79486699365b22231 100644 --- a/src/client/components/AntibodiesTable.vue +++ b/src/client/components/AntibodiesTable.vue @@ -68,6 +68,7 @@ </th> </tr> </thead> + <tbody> <tr v-for="antibody in antibodies" @@ -78,7 +79,8 @@ data-label="ID" class="link-cell" > - <RouterLink :to="{ name: 'AntibodyPage', params: { hashId: antibody.hashId } }"> + <span class="loader" v-if="isFetchingAntibodies"></span> + <RouterLink v-else :to="{ name: 'AntibodyPage', params: { hashId: antibody.hashId } }"> {{ antibody.id }} </RouterLink> </td> @@ -86,25 +88,29 @@ headers="antibodies-species" data-label="SPECIES" > - <i>{{ antibody.species }}</i> + <span class="loader" v-if="isFetchingAntibodies"></span> + <i v-else>{{ antibody.species }}</i> </td> <td headers="antibodies-heavy-chain" data-label="HEAVY CHAIN" > - {{ antibody.heavyChain.sequence.substring(0, 15) }}... + <span class="loader" v-if="isFetchingAntibodies"></span> + <span v-else>{{ antibody.heavyChain.sequence.substring(0, 15) }}...</span> </td> <td headers="antibodies-light-chain" data-label="LIGHT CHAIN" > - {{ antibody.lightChain.sequence.substring(0, 15) }}... + <span class="loader" v-if="isFetchingAntibodies"></span> + <span v-else>{{ antibody.lightChain.sequence.substring(0, 15) }}...</span> </td> <td headers="antibodies-sources" data-label="SOURCES" > - {{ listSources(antibody).join(', ') }} + <span class="loader" v-if="isFetchingAntibodies"></span> + <span v-else>{{ listSources(antibody).join(', ') }}</span> </td> </tr> </tbody> @@ -124,6 +130,7 @@ import AntibodiesTableTotal from './AntibodiesTableTotal.vue'; const store = useStore() const antibodies = computed(() => { return store.antibodies }) +const isFetchingAntibodies = computed(() => { return store.isFetchingAntibodies }) /** * Sort the table by refetching the results and make the sort @@ -132,6 +139,8 @@ const antibodies = computed(() => { return store.antibodies }) * @param key - The antibody key corresponding to the column to sort by */ function sortBy(key: string): void { + if (isFetchingAntibodies.value) return + if (store.request.sort.by === key) { store.request.sort.order = store.request.sort.order === 'asc' ? 'desc' : 'asc' store.fetchAntibodies(store.request.fetchParams()) @@ -191,10 +200,11 @@ function listSources(antibody: Antibody): string[] { .link-cell a { display: inline-block; + max-width: 200px; overflow: hidden; text-overflow: ellipsis; + vertical-align: middle; white-space: nowrap; - max-width: 200px; } @media screen and (width < 700px) { diff --git a/src/client/components/AntibodiesTableDownload.vue b/src/client/components/AntibodiesTableDownload.vue index da2356085a51fd511f05bc8589b8b1ccb9811703..9d41c1d9559c1ce0e56fdb9f1b2f8e984a0aaef8 100644 --- a/src/client/components/AntibodiesTableDownload.vue +++ b/src/client/components/AntibodiesTableDownload.vue @@ -3,7 +3,7 @@ <button class="download-button" type="button" - :disabled="isDownloading" + :disabled="isFetchingAntibodies" @click="download" > <span class="download-button-text"> @@ -48,6 +48,7 @@ const store = useStore() const request = computed(() => { return store.request }) const antibodiesCount = computed(() => { return store.antibodiesCount }) const isDownloading = computed(() => { return store.isDownloading }) +const isFetchingAntibodies = computed(() => { return store.isFetchingAntibodies }) const isFetchingCount = computed(() => { return store.isFetchingCount }) const downloadFormat: Ref<APIDownloadFormat> = ref('fasta') diff --git a/src/client/components/AntibodiesTablePagination.vue b/src/client/components/AntibodiesTablePagination.vue index 5431d30b5abebc6c5bab370666eb7e4e51fdcdf5..51299cc2ba7c0ca02099cca1f5b8843905416474 100644 --- a/src/client/components/AntibodiesTablePagination.vue +++ b/src/client/components/AntibodiesTablePagination.vue @@ -3,6 +3,7 @@ <button class="previous-button" type="button" + :disabled="isFetchingAntibodies" @click="getPrevious" > < @@ -11,6 +12,7 @@ <button class="next-button" type="button" + :disabled="isFetchingAntibodies" @click="getNext" > > @@ -30,6 +32,9 @@ const antibodiesCount = computed(() => { return store.antibodiesCount }) const pagesCount = computed(() => { return Math.ceil(antibodiesCount.value / rowsPerPage) }) +const isFetchingAntibodies = computed(() => { + return store.isFetchingAntibodies +}) /** * Reset the pagination if the request has changed. diff --git a/src/client/components/SearchBar.vue b/src/client/components/SearchBar.vue index ef97a3f9359a2882050d986defc71052023f93a0..bb062c6c83448f4598a6df73a003dbb2c0534153 100644 --- a/src/client/components/SearchBar.vue +++ b/src/client/components/SearchBar.vue @@ -18,6 +18,7 @@ <button class="search-button" type="button" + :disabled="isFetchingAntibodies" @click="fetchAntibodies"> SEARCH </button> @@ -28,11 +29,13 @@ </template> <script setup lang="ts"> +import { computed } from 'vue' import { useStore } from '../store' import type { AdvancedFilters } from '../types' import AdvancedSearch from './AdvancedSearch.vue' const store = useStore() +const isFetchingAntibodies = computed(() => { return store.isFetchingAntibodies }) /** * When the advanced filters are changed, store them locally and diff --git a/src/client/components/TheDownloadPage.vue b/src/client/components/TheDownloadPage.vue index 4334bf80ce75c913cc2e9228e890217db3c72629..8467f5b18f93a164a589f49fd7aab8916a727c2d 100644 --- a/src/client/components/TheDownloadPage.vue +++ b/src/client/components/TheDownloadPage.vue @@ -32,18 +32,34 @@ const props = defineProps<{ archiveId: string }>() +/** The link to the archive when its ready */ const archiveLink = ref<string>() + +/** ID of the interval timer started on page load */ let intervalID: number +/** + * On page load, start a timer to fetch the file status + * every 3 seconds. + */ onMounted(() => { getFileLink() - intervalID = setInterval(getFileLink, 2000) + intervalID = setInterval(getFileLink, 3000) }) +/** + * Remove the timer when leaving the page to ensure + * memory safety. + */ onUnmounted(() => { clearInterval(intervalID) }) +/** + * Get the status of the building archive and get + * the link to if its ready. + * Used with the interval timer. + */ function getFileLink() { api.fetchUserMadeArchive(props.archiveId).then((response) => { if (response.status === 202) return diff --git a/src/client/components/TheHomePage.vue b/src/client/components/TheHomePage.vue index 640b4a4692ff12b494080a7c0842be35ef9e8809..e575d38c33580549caaffa245e49d65dd46208df 100644 --- a/src/client/components/TheHomePage.vue +++ b/src/client/components/TheHomePage.vue @@ -126,7 +126,7 @@ <SearchBar /> - <AntibodiesTable v-if="antibodies.length && !noResults" /> + <AntibodiesTable v-if="!noResults" /> <NoResults v-if="noResults" /> </template> @@ -139,7 +139,6 @@ import NoResults from './NoResults.vue' import SearchBar from './SearchBar.vue' const store = useStore() -const antibodies = computed(() => { return store.antibodies }) const noResults = computed(() => { return store.noResults }) /** diff --git a/src/client/globals.css b/src/client/globals.css index c7c81b04ae7883a831ce8b62632abae7a6f52f3c..2cd5a8a528d8157db676ab2a8f54797d9d59d8a2 100644 --- a/src/client/globals.css +++ b/src/client/globals.css @@ -43,6 +43,7 @@ --black: hsl(0, 0%, 4%); --black-translucent: hsla(0, 0%, 4%, 0.5); --grey: hsl(0, 0%, 90%); + --grey-translucent: hsl(0, 0%, 90%, 0.5); --white: hsl(0, 0%, 100%); --spacing: 20px; @@ -106,17 +107,20 @@ button { padding: var(--button-padding); transition: background-color 0.1s; - &:hover { - border-color: var(--primary); - color: var(--primary); - } - &:active { background-color: var(--primary-translucent); } + &:hover:enabled { + border-color: var(--primary); + color: var(--primary); + } + &:disabled { cursor: not-allowed; + color: var(--grey); + background-color: var(--grey-translucent); + border-color: var(--grey); } } @@ -135,13 +139,13 @@ i { font-weight: bold; padding: var(--half-spacing) var(--spacing); - &:hover { + &:hover:enabled { background-color: transparent; border-color: var(--primary); color: var(--primary); } - &:active { + &:active:enabled { background-color: var(--primary); color: var(--black); } @@ -167,3 +171,32 @@ i { transform: rotate(360deg); } } + +.loader { + width: 200px; + height: 18px; + display: inline-block; + background-color: var(--primary-translucent); + border-radius: var(--radius); + background-image: linear-gradient( + 45deg, + var(--primary) 25%, + transparent 25%, + transparent 50%, + var(--primary) 50%, + var(--primary) 75%, + transparent 75% + ); + background-size: 1em 1em; + box-sizing: border-box; + animation: bar-stripe 0.5s linear infinite; +} + +@keyframes bar-stripe { + 0% { + background-position: 1em 0; + } + 100% { + background-position: 0 0; + } +} diff --git a/src/client/store.ts b/src/client/store.ts index 948e4eca85d68f3b2047742266db4b5d94100af7..149d0f742285bbeaa9c091dbc906e416b299eb3b 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -20,13 +20,10 @@ export const useStore = defineStore('store', { return { antibodies: [], antibodiesCount: 0, - antibodiesSources: [], - antibodiesSpecies: [], - antibodiesHeavySegments: [], - antibodiesLightSegments: [], archives: [], statistics: null, isDownloading: false, + isFetchingAntibodies: false, isFetchingCount: false, request: new APIRequest(), noResults: false, @@ -34,34 +31,74 @@ export const useStore = defineStore('store', { } }, getters: { + /** + * Get the current list of sources from the statistics. + * @param state - The state of the store + * @returns The list of sources from the stats + */ antibodiesSources: (state): Statistics['sources'] => { return state.statistics?.sources || [] }, + + /** + * Get the current list of species from the statistics. + * @param state - The state of the store + * @returns The list of species from the stats + */ antibodiesSpecies: (state): Statistics['species'] => { return state.statistics?.species || [] }, - antibodiesHeavySegments: (state) => { + + /** + * Get the current list of heavy segments from the statistics. + * @param state - The state of the store + * @returns The list of heavy segments from the stats + */ + antibodiesHeavySegments: (state): Array<keyof Statistics['antibodiesPerHeavySegment']> => { return Object .keys(state.statistics?.antibodiesPerHeavySegment || {}) .sort(alphanumSort) }, - antibodiesLightSegments: (state) => { + + /** + * Get the current list of light segments from the statistics. + * @param state - The state of the store + * @returns The list of light segments from the stats + */ + antibodiesLightSegments: (state): Array<keyof Statistics['antibodiesPerHeavySegment']> => { return Object .keys(state.statistics?.antibodiesPerLightSegment || {}) .sort(alphanumSort) } }, actions: { + /** + * The main action for fetching antibodies from the server, + * used inside components. + * Stores the results in the corresponding store variable + * instead of returning anything. + * @param request - The request parameters for getting antibodies + */ fetchAntibodies(request: APIFetchParams): void { + this.isFetchingAntibodies = true api.fetchAntibodies(request).then(response => { this.noResults = response.data.length === 0 this.antibodies = response.data }).catch((err: AxiosError) => { alert(err.message) console.log(err) + }).finally(() => { + this.isFetchingAntibodies = false }) }, + /** + * The main action to count the total number of antibodies + * matching a request. + * Stores the results in the corresponding store variable + * instead of returning anything. + * @param countRequest - The request parameters for counting + */ countAntibodies(countRequest: APICountParams): void { // Avoid to send an empty request to get an information // we already have in the stats. @@ -81,6 +118,12 @@ export const useStore = defineStore('store', { }) }, + /** + * Action to trigger the building of an archive by the server + * in order to download it. + * Re-route to the download waiting page. + * @param request - The request parameters to filter antibodies and set the archive format + */ downloadAntibodies(request: APIDownloadParams): void { this.isDownloading = true api.downloadAntibodies(request).then(response => { @@ -98,6 +141,10 @@ export const useStore = defineStore('store', { }) }, + /** + * Get the database statistics from the server and stores it in the + * corresponding variable in the store. + */ getStatistics(): void { api.getStatistics().then(response => { this.statistics = Object.freeze(response.data) @@ -107,12 +154,23 @@ export const useStore = defineStore('store', { }) }, + /** + * Build a link to an antibody external resource using the template + * in the sources metadata. Those can be found in the `data/sources.json` file. + * @param id - One of the antibody ID found in its FASTA headers + * @param source - The name of the source + * @returns The corresponding link, usually a url. + */ getSourceLink(id: Antibody['id'], source: FastaHeader['source']): string { const link = this.sourceMeta[source]?.link if (link === undefined) return '' return link.replace(/{id}/g, id) }, + /** + * Fetch the list of pre-made archives from the server + * and stores it in the store. + */ fetchArchives(): void { api.fetchArchives().then(response => { this.archives = response.data diff --git a/src/client/types.ts b/src/client/types.ts index 18b8e34532322ba7fd595b58476bc4bfbbd6314e..b04bbfd1beb492f8ef899512e66dc3e3f3f73006 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -9,13 +9,10 @@ import type { APIRequest } from './models/APIRequest' export interface AppState { antibodies: Antibody[] antibodiesCount: number - antibodiesSources: Readonly<Array<FastaHeader['source']>> - antibodiesSpecies: Readonly<Array<Antibody['species']>> - antibodiesHeavySegments: Readonly<Array<FastaHeader['vGeneSegment']>> - antibodiesLightSegments: Readonly<Array<FastaHeader['vGeneSegment']>> archives: Array<string> statistics: Readonly<Statistics> | null isDownloading: boolean + isFetchingAntibodies: boolean isFetchingCount: boolean noResults: boolean request: APIRequest @@ -72,8 +69,8 @@ export interface Statistics { antibodiesPerSource: Record<FastaHeader['source'], number>, antibodiesOnlyInSource: Record<FastaHeader['source'], number> antibodiesInMultipleSources: Record<string, number> - antibodiesPerHeavySegment: Record<string, number> - antibodiesPerLightSegment: Record<string, number> + antibodiesPerHeavySegment: Record<FastaHeader['vGeneSegment'], number> + antibodiesPerLightSegment: Record<FastaHeader['vGeneSegment'], number> } /** @@ -131,15 +128,6 @@ export interface APICountResponse { count: number } -/** - * The response from the server when fetching the lists - * of unique V gene segments present in the database. - */ -export interface APISegmentsResponse { - heavySegments: Array<FastaHeader['vGeneSegment']> - lightSegments: Array<FastaHeader['vGeneSegment']> -} - /** * The response from the server when requesting a download * of the main table data. It only returns the name of the diff --git a/src/server/routes/statisticsRoute.js b/src/server/routes/statisticsRoute.js index eb033bcdf0944c0bd496d6f75df5e54b1989d0a1..ebf884c74f9c29cb27881abe92350474b87d5873 100644 --- a/src/server/routes/statisticsRoute.js +++ b/src/server/routes/statisticsRoute.js @@ -23,7 +23,9 @@ export default { handler: function (request, reply) { this.mongo.db .collection('statistics') - .findOne() + .findOne({}, { + projection: { _id: false } + }) .then(stats => { return reply .code(200)