Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add facility search bar component #925

Merged
merged 6 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ cypress/videos
*.DS_Store

.env
.vscode
10 changes: 10 additions & 0 deletions assets/icons/check-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 42 additions & 2 deletions components/ModEditHealthcareProfessionalSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,28 @@
</option>
</select>
</div>
<h2
class="mod-healthcare-professional-section
my-3.5 text-start text-primary-text text-2xl font-bold font-sans leading-normal"
>
{{ $t("modHealthcareProfessionalSection.facilities") }}
</h2>
<!-- This is just an example how to use it -->
<ModSearchbar
v-model="selectedFacilities"
:place-holder-text="$t('modHealthcareProfessionalSection.placeholderTextFacilitySearchBar')"
:no-match-text="$t('modHealthcareProfessionalSection.noFacilitiesWereFound')"
:fields-to-display-callback="fieldsToDisplayCallback"
@search-input-change="handleSearchInputChange"
/>
<ul>
<li
v-for="facility in selectedFacilities"
:key="facility.id"
>
{{ `${facility.id} / ${facility.nameEn} / ${facility.nameJa}` }}
</li>
</ul>
</div>
</div>
</template>
Expand All @@ -177,18 +199,24 @@
import { nextTick, onBeforeMount, onUpdated, type Ref, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { type ToastInterface, useToast } from 'vue-toastification'
import ModSearchbar from './ModSearchBar.vue'
import { ModerationScreen, useModerationScreenStore } from '~/stores/moderationScreenStore'
import { useHealthcareProfessionalsStore } from '~/stores/healthcareProfessionalsStore'
import { Locale, Insurance, Degree, Specialty, type LocalizedNameInput } from '~/typedefs/gqlTypes'
import { Locale, Insurance, Degree, Specialty, type LocalizedNameInput, type Facility } from '~/typedefs/gqlTypes'
import { multiSelectWithoutKeyboard } from '~/utils/multiSelectWithoutKeyboard'
import { useI18n } from '#imports'
import { useFacilitiesStore, useI18n } from '#imports'

let toast: ToastInterface

const route = useRoute()

const { t } = useI18n()

const selectedFacilities = ref(new Set<Facility>())
const facilitiesStore = useFacilitiesStore()
await facilitiesStore.getFacilities() // Fix a bug where facilities disappear after the user refreshes the page
const currentFacilities = facilitiesStore.facilityData

const moderationScreenStore = useModerationScreenStore()
const healthcareProfessionalsStore = useHealthcareProfessionalsStore()
const isEditSubmissionScreen = moderationScreenStore.activeScreen === ModerationScreen.EditSubmission
Expand Down Expand Up @@ -230,6 +258,18 @@ const handleLocalizedName = () => {
}
}

const handleSearchInputChange = (filteredItems: Ref<Facility[]>, inputValue: string) => {
filteredItems.value = currentFacilities.filter(({ nameEn, nameJa, id }) => {
const isMatch
= nameEn.toLowerCase().includes(inputValue)
|| nameJa.toLowerCase().includes(inputValue)
|| id.toLowerCase() === inputValue
return isMatch
})
}

const fieldsToDisplayCallback = (item: Facility) => [item.nameEn, item.nameJa]

onBeforeMount(async () => {
isHealthcareProfessionalInitialized.value = false

Expand Down
252 changes: 252 additions & 0 deletions components/ModSearchBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
<template>
<div
class="container flex gap-1 w-fit"
:class="[
isNearPageBottom ? 'flex-col-reverse' : 'flex-col',
]"
>
<div
class="flex items-center px-3 py-3.5 w-96 h-12 bg-secondary-bg
rounded-lg border border-primary-text-muted text-primary-text
text-sm font-normal font-sans placeholder-primary-text-muted
outline outline-0 -outline-offset-2 gap-2 divide-x-2
focus-within:outline-currentColor
focus-within:outline-2
"
>
<input
ref="searchInputElement"
type="text"
:placeholder="placeHolderText"
class="grow focus-visible:outline-none"
@blur="handleSearchInputBlur"
@keydown.esc="handleSearchInputBlur"
@keydown.down="handleSearchInputArrowDown"
@keydown.up="handleSearchInputArrowUp"
@keydown.enter="handleSearchInputEnter"
@input="handleSearchInputChange"
>
<span
v-if="searchResultCount > 0"
class="pl-2"
>
{{ searchResultCount }}
</span>
<button
type="button"
@click="searchInputElement?.focus()"
>
<SVGLookingGlass
role="img"
title="searching icon"
class="h-6 pl-2"
/>
</button>
</div>
<div class="container relative">
<ul
v-if="searchInputValue.trim() !== ''"
id="search-list"
class="bg-primary-bg shadow-md border-2 border-primary/50 rounded-lg absolute
w-full flex flex-col divide-y-2 max-h-64 overflow-y-auto overflow-x-hidden
"
:class="[
isNearPageBottom ? '-translate-y-full' : '',
]"
>
<li
v-for="(item, index) in filteredItems"
:id="`search-list-item-${index}`"
:key="item.id"
class="flex justify-between divide-x cursor-pointer"
:class="[
selectedItems.has(item) ? 'bg-primary/90' : '',
selectedItemIndex === index ? 'bg-primary-hover text-primary-inverted' : 'opacity-95',
]"
@click="handleListItemClick"
@mousedown="handleListItemMouseDown"
@mouseover="() => { handleListItemMouseOver(index) }"
>
<div class="flex items-center m-3 gap-3 overflow-x-auto">
<span>{{ index + 1 }}</span>
<div class="flex flex-col overflow-x-auto whitespace-nowrap">
<span class="text-xs">{{ item.id }}</span>
<div class="divide-x-2 mt-1">
<!--
Originally, the 'item' type is UnwrapRefSimple<SetType<T>>,
but we don't have access to UnwrapRefSimple,
so we need to directly set the type as SetType<T>.
-->
<span
v-for="(field, fieldIndex) in fieldsToDisplayCallback(item as SetType<T>)"
:key="fieldIndex"
class="px-2 first-of-type:pl-0 last-of-type:pr-0"
>
{{ field }}
</span>
</div>
</div>
</div>
<div class="flex items-center">
<SVGCheckMark
class="h-4 m-3"
:class="[
selectedItems.has(item) ? 'opacity-100' : 'opacity-10',
]"
/>
</div>
</li>
<!-- Fallback for empty search results -->
<li
v-if="!filteredItems.length"
class="m-3 cursor-default"
>
<span>{{ noMatchText }}</span>
</li>
</ul>
</div>
</div>
</template>

<script setup lang="ts" generic="T extends Set<any>">
/*
Vue Generics: https://vuejs.org/api/sfc-script-setup.html#generics
Defining generic types using props
*/
import { computed, ref, watch, type Ref } from 'vue'
import SVGCheckMark from '~/assets/icons/check-mark.svg'
import SVGLookingGlass from '~/assets/icons/looking-glass.svg'

// Obtain the Set inner type
type SetType<V> = V extends Set<infer U> ? U : never

const emit = defineEmits<{
searchInputBlur: []
searchInputArrowDown: []
searchInputArrowUp: []
searchInputEnter: []
searchInputChange: [filteredItems: Ref<SetType<T>[]>, inputValue: string]
}>()

// Using a type from the user. T is defined when selectedItems is passed down.
const selectedItems = defineModel<T>({ required: true })

type Props = {
placeHolderText: string
noMatchText: string
// Callback to display the desired output
fieldsToDisplayCallback: (item: SetType<T>) => string[]
}

const { placeHolderText, noMatchText, fieldsToDisplayCallback } = defineProps<Props>()

const searchInputElement = ref<HTMLInputElement>()
const searchInputValue = ref('')
const filteredItems = ref<SetType<T>[]>([]) // We want only the inner type of the Set for the array.
const selectedItemIndex = ref(0)
const searchResultCount = ref(0)

const handleListScroll = () => {
const selectedElement = document.getElementById(`search-list-item-${selectedItemIndex.value}`)

if (!selectedElement) return

selectedElement.scrollIntoView({
block: 'nearest',
inline: 'nearest'
})
}

const handleListItem = () => {
if (filteredItems.value.length === 0) return

const item = filteredItems.value[selectedItemIndex.value]

if (selectedItems.value.has(item)) {
selectedItems.value.delete(item)
return
}

selectedItems.value.add(item)
}

const handleListItemClick = (event: MouseEvent) => {
event.preventDefault()
handleListItem()
}

const handleListItemMouseOver = (index: number) => {
selectedItemIndex.value = index
}

const handleListItemMouseDown = (event: MouseEvent) => {
// Prevent search input from being blurred
event.preventDefault()
}

const handleSearchInputBlur = (event: Event) => {
if (!searchInputElement.value) return
searchInputElement.value.value = ''
handleSearchInputChange(event)
emit('searchInputBlur')
}

const handleSearchInputArrowUp = (event: KeyboardEvent) => {
event.preventDefault()
if (selectedItemIndex.value > 0) {
--selectedItemIndex.value
handleListScroll()
}
emit('searchInputArrowUp')
}

const handleSearchInputArrowDown = (event: KeyboardEvent) => {
event.preventDefault()
if (selectedItemIndex.value < filteredItems.value.length - 1) {
++selectedItemIndex.value
handleListScroll()
}
emit('searchInputArrowDown')
}

const handleSearchInputEnter = (event: KeyboardEvent) => {
event.preventDefault()
handleListItem()
emit('searchInputEnter')
}

const handleSearchInputChange = (event: Event) => {
const eventTarget = event.target as HTMLInputElement

searchInputValue.value = eventTarget.value

const inputValue = eventTarget.value.toLowerCase()

selectedItemIndex.value = 0

if (inputValue.trim() === '') {
filteredItems.value = []
return
}

/*
We need to unwrap the `filteredItems` type since the original type is:
Ref<UnwrapRefSimple<SetType<T>>[], SetType<T>[] | UnwrapRefSimple<SetType<T>>[]>

We can't use UnwrapRefSimple as it is an internal Vue type. So, we need to explicitly
define this variable as Ref<SetType<T>[]>.
*/

emit('searchInputChange', filteredItems as Ref<SetType<T>[]>, inputValue)
}

// Displays the dropdown above the searchInputElement if its position is greater than window.innerHeight / 1.5
const isNearPageBottom = computed(() => {
if (!searchInputElement.value) return
return searchInputElement.value.getBoundingClientRect().bottom >= window.innerHeight / 1.5
})

watch(() => filteredItems.value, value => {
searchResultCount.value = value.length
})
</script>
5 changes: 4 additions & 1 deletion i18n/locales/cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@
"selectSpecialties": "Specialties",
"selectDegrees": "Degrees",
"selectLocales": "Select Locales Spoken",
"facilities": "Facilities",
"placeholderTextFacilitySearchBar": "Search by name or Id add a facility",
"noFacilitiesWereFound": "No facilities were found",
"errorMessageHealthcareProfessionalId": "Healthcare Professional Id was not found"
},
"modSubmissionForm": {
Expand Down Expand Up @@ -364,4 +367,4 @@
"editName": "Edit",
"saveName": "Save"
}
}
}
5 changes: 4 additions & 1 deletion i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@
"selectSpecialties": "Specialties",
"selectDegrees": "Degrees",
"selectLocales": "Select Locales Spoken",
"facilities": "Facilities",
"placeholderTextFacilitySearchBar": "Search by name or Id add a facility",
"noFacilitiesWereFound": "No facilities were found",
"errorMessageHealthcareProfessionalId": "Healthcare Professional Id was not found"
},
"modSubmissionForm": {
Expand Down Expand Up @@ -364,4 +367,4 @@
"editName": "Edit",
"saveName": "Save"
}
}
}
7 changes: 5 additions & 2 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"UNAUTHENTICATED": "You do not have the authentication to do this action",
"BAD_USER_INPUT": "One or more of the inputs entered is invalid, please try again",
"errorCodeMessagingNeeded": "The server error code does not have a message for the user. Please add one.",
"genericErrorMessage": "There was an error submitting your form, please try again"
"genericErrorMessage": "There was an error submitting your form, please try again"
},
"footer": {
"terms": "Terms",
Expand Down Expand Up @@ -204,7 +204,10 @@
"selectDegrees": "Degrees",
"selectInsurances": "Accepted Insurances",
"selectLocales": "Select Locales Spoken",
"selectSpecialties": "Specialties"
"selectSpecialties": "Specialties",
"facilities": "Facilities",
"placeholderTextFacilitySearchBar": "Search by name or Id add a facility",
"noFacilitiesWereFound": "No facilities were found"
},
"modFacilitySection": {
"addresses": "Addresses",
Expand Down
Loading
Loading