Skip to content

Commit

Permalink
Merge pull request #9 from deetz99/reg-search
Browse files Browse the repository at this point in the history
BRD - UI: Reg search
  • Loading branch information
deetz99 authored Aug 15, 2024
2 parents a205e1a + 9a94c3c commit b79092c
Show file tree
Hide file tree
Showing 12 changed files with 643 additions and 34 deletions.
41 changes: 28 additions & 13 deletions business-registry-dashboard/.env.sample
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
## vaults api
# registries search api
NUXT_REGISTRIES_SEARCH_API_URL="https://bcregistry-test.apigee.net/registry-search"
NUXT_REGISTRIES_SEARCH_API_VERSION="/api/v2"
NUXT_X_API_KEY=""
# pay API
NUXT_PAY_API_URL=""
NUXT_PAY_API_URL="https://pay-api-dev.apps.silver.devops.gov.bc.ca"
NUXT_PAY_API_VERSION="/api/v1"
# auth api
NUXT_AUTH_API_URL="https://auth-api-test.apps.silver.devops.gov.bc.ca"
NUXT_AUTH_API_VERSION="/api/v1"
# legal API
NUXT_LEGAL_API_URL="https://legal-api-test.apps.silver.devops.gov.bc.ca"
NUXT_LEGAL_API_VERSION="/api/v2"

# business ar api
NUXT_BAR_API_URL=""
NUXT_BAR_API_VERSION="/v1"
# vaults web-url
NUXT_REGISTRY_HOME_URL="https://dev.bcregistry.gov.bc.ca/"
NUXT_PAYMENT_PORTAL_URL="https://dev.account.bcregistry.gov.bc.ca/makepayment/"
NUXT_NAME_REQUEST_URL="https://dev.names.bcregistry.gov.bc.ca/"
NUXT_ONE_STOP_URL="https://dev.onestop.gov.bc.ca/"
NUXT_APP_SOCIETIES_URL="https://www.bcregistry.ca/societies/"
NUXT_APP_CORP_FORMS_URL="https://www2.gov.bc.ca/gov/content/employment-business/business/managing-a-business/permits-licences/businesses-incorporated-companies/forms-corporate-registry"
NUXT_APP_LLP_FORMS_URL="https://www2.gov.bc.ca/assets/gov/employment-business-and-economic-development/business-management/permits-licences-and-registration/registries-packages/pack_01_llp_-_registration_forms_package.pdf"
NUXT_APP_LP_FORMS_URL="https://www2.gov.bc.ca/assets/gov/employment-business-and-economic-development/business-management/permits-licences-and-registration/registries-forms/reg_791_-_declaration_for_bc_limited_partnership.pdf"
NUXT_APP_XLP_FORMS_URL="https://www2.gov.bc.ca/assets/gov/employment-business-and-economic-development/business-management/permits-licences-and-registration/registries-forms/reg_790_-_declaration_for_extraprovincial_limited_partnership.pdf"
NUXT_DASHBOARD_URL="https://dev.business.bcregistry.gov.bc.ca/"
NUXT_CORPORATE_ONLINE_URL="https://www.corporateonline.gov.bc.ca"
NUXT_AUTH_WEB_URL="https://dev.account.bcregistry.gov.bc.ca/"
NUXT_BASE_URL="http://localhost:3000/" # app base url

#vaults keycloak
NUXT_KEYCLOAK_AUTH_URL=""
NUXT_KEYCLOAK_REALM=""
NUXT_KEYCLOAK_CLIENTID=""

# web urls
NUXT_REGISTRY_HOME_URL=""
NUXT_PAYMENT_PORTAL_URL=""

# business ar url
NUXT_BASE_URL="http://localhost:3000/"
# vaults launch darkly
NUXT_LD_CLIENT_ID=""

NUXT_ENVIRONMENT_HEADER="Development"

# launch darkly client id
NUXT_LD_CLIENT_ID=""
89 changes: 89 additions & 0 deletions business-registry-dashboard/app/components/AsyncComboBox/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { UInput } from '#components'
const emit = defineEmits<{select: [item: RegSearchResult]}>()
const inputRef = ref<InstanceType<typeof UInput> | null>(null)
const resultListItems = ref<NodeListOf<HTMLLIElement> | null>(null)
const combo = reactive(useComboBox(inputRef, resultListItems, (item: RegSearchResult) => emit('select', item)))
</script>
<template>
<div>
<UInput
id="business-search-input"
ref="inputRef"
type="text"
role="combobox"
:value="combo.query"
:aria-expanded="combo.showDropdown"
aria-controls="search-results-container"
autocomplete="off"
aria-autocomplete="list"
variant="bcGovLg"
:loading="combo.loading"
:placeholder="$t('page.home.busOrNRSearch.placeholder')"
:ui="{
base: 'bg-white',
placeholder: 'placeholder-gray-400 placeholder:text-base',
icon: {
base: combo.hasFocus ? 'text-blue-500' : 'text-gray-600',
size: { sm: 'size-6' }
}
}"
icon="i-mdi-magnify"
trailing
@keyup="combo.keyupHandler"
@focus="combo.hasFocus = true"
@blur="combo.hasFocus = false"
@input="combo.handleInput"
/>
<div
v-show="combo.showDropdown && combo.hasFocus && !combo.loading"
class="absolute z-[999] max-h-72 w-full overflow-auto rounded-b-md bg-white shadow-md"
>
<div v-if="combo.error">
<slot name="error" />
</div>
<div v-else-if="combo.results.length === 0">
<slot name="empty" />
</div>
<ul
v-else
id="search-results-container"
role="listbox"
aria-label="Business Search Results"
>
<li
v-for="item in combo.results"
:id="item.identifier"
ref="resultListItems"
:key="item.identifier"
role="option"
aria-selected="false"
class="cursor-pointer p-4 transition-colors ease-linear hover:bg-[#e4edf7] aria-selected:bg-[#e4edf7]"
@mousedown="combo.emitSearchResult(item)"
>
<div class="flex items-center justify-between">
<div class="flex-1">
<span>{{ item.identifier }}</span>
</div>
<div class="flex-1 px-2">
<span>{{ item.name }}</span>
</div>
<div class="flex-1 text-right">
<span class="text-sm text-blue-500">{{ $t('words.select') }}</span>
</div>
</div>
</li>
</ul>
</div>
<span
v-if="combo.query !== ''"
role="status"
class="sr-only"
>
{{ combo.statusText }}
</span>
</div>
</template>
243 changes: 243 additions & 0 deletions business-registry-dashboard/app/composables/useComboBox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { UInput } from '#components'
import { KeyCode } from '~/enums/key-codes'

export const useComboBox = (
inputRef: Ref<InstanceType<typeof UInput> | null>,
resultListItems: Ref<NodeListOf<HTMLLIElement> | null>,
onSelect: (item: RegSearchResult) => void
) => {
const config = useRuntimeConfig().public
const accountStore = useConnectAccountStore()
const keycloak = useKeycloak()

const query = ref('')
const loading = ref(false)
const showDropdown = ref(false)
const hasFocus = ref(false)
const results = ref<RegSearchResult[]>([])
const statusText = ref('')
const error = ref(false)

// Helper function to reset dropdown states
function resetDropdown () {
showDropdown.value = false
resetActiveElement()
results.value = []
}

const fetchResults = async () => {
try {
if (query.value.trim() === '') { // return if query is empty
resetDropdown()
return
}

statusText.value = ''
error.value = false

const token = await keycloak.getToken()
const response = await $fetch<RegSearchResponse>(`${config.regSearchApiUrl}/search/businesses`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-apikey': config.xApiKey,
'Account-Id': accountStore.currentAccount.id
},
body: {
query: {
value: query.value
},
categories: {
status: ['ACTIVE'],
legalType: ['A', 'BC', 'BEN', 'C', 'CBEN', 'CC', 'CCC', 'CP', 'CUL', 'FI', 'GP', 'LL', 'LLC', 'LP', 'PA', 'S', 'SP', 'ULC', 'XCP', 'XL', 'XP', 'XS']
},
rows: 20,
start: 0
}
})

if (response.searchResults.results.length >= 0) {
results.value = response.searchResults.results
setTimeout(() => {
statusText.value = `${results.value.length} results`
}, 300) // delay so screen reader is updated correctly
}
} catch (e) {
console.error('Error fetching search results:', e)
error.value = true
setTimeout(() => {
statusText.value = 'Error retrieving search results'
}, 300) // delay so screen reader is updated correctly
} finally {
loading.value = false
}
}

const debouncedFetchResults = useDebounceFn(fetchResults, 500)

const getResults = () => {
loading.value = true
results.value = []
debouncedFetchResults()
}

function setActiveElement (element: HTMLElement) {
// reset all elements first
resetActiveElement()
// set active <li> element
element.setAttribute('aria-selected', 'true')
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })

// set <input> active-descendant attr to correct <li> item
inputRef.value?.input.setAttribute('aria-activedescendant', element.id)
}

function resetActiveElement () {
// remove aria-selected attr from all result <li>s
resultListItems.value?.forEach((item) => {
item.removeAttribute('aria-selected')
})

// remove aria-activedescendant attr from <input>
inputRef.value?.input.removeAttribute('aria-activedescendant')
}

// find active <li> index in search results list
function getActiveElementIndex () {
if (resultListItems.value) {
// convert nodelist into array
const resultArray = Array.from(resultListItems.value)

// return active index
return resultArray.findIndex(
el => el.getAttribute('aria-selected') === 'true'
)
} else {
return -1
}
}

function keyupHandler (e: KeyboardEvent) {
const allowedKeys = [KeyCode.ARROWUP, KeyCode.ARROWDOWN, KeyCode.ENTER, KeyCode.ESCAPE, KeyCode.TAB]
const key = e.code as KeyCode
const activeElIndex = getActiveElementIndex() // get aria-activedescendant index or return -1
const resultMax = results.value.length - 1 // last <li> element index

if (key === KeyCode.TAB) {
resetDropdown()
}

// only continue if the <input> is focused and the dropdown is open
if (hasFocus.value && showDropdown.value) {
// remove aria-activedescendant and aria-active if user had an item selected but then keeps typing
if (!allowedKeys.includes(key) && activeElIndex >= 0) {
resetActiveElement()
return
}
switch (key) {
// handle arrowdown
case KeyCode.ARROWDOWN: {
e.preventDefault()
if (!resultListItems.value || resultListItems.value?.length === 0) { return } // return early if in error or no results state
// set focus to first <li> if no currently active <li> or if event fired from last <li>, else add 1
const nextIndex = activeElIndex === -1 || activeElIndex === resultMax ? 0 : activeElIndex + 1
const nextElement = resultListItems.value[nextIndex]
if (nextElement) {
setActiveElement(nextElement)
}
break
}
// handle arrow up
case KeyCode.ARROWUP: {
e.preventDefault()
if (!resultListItems.value || resultListItems.value.length === 0) { return } // return early if in error or no results state
const prevIndex = activeElIndex <= 0 ? resultMax : activeElIndex - 1
const prevElement = resultListItems.value[prevIndex]
if (prevElement) {
setActiveElement(prevElement)
}
break
}
// handle enter
case KeyCode.ENTER:
e.preventDefault()
// do nothing if no active element
if (activeElIndex >= 0 && results.value[activeElIndex]) {
emitSearchResult(results.value[activeElIndex] as RegSearchResult)
}
break
// handle escape, close dropdown, reset active element and search results
case KeyCode.ESCAPE:
e.preventDefault()
resetDropdown()
break

default:
break
}
// allow enter and escape keys if input is not empty, has focus and dropdown is closed
} else if (
hasFocus.value &&
!showDropdown.value &&
query.value !== ''
) {
switch (key) {
// clear input field and reset search results
case KeyCode.ESCAPE:
e.preventDefault()
query.value = ''
resetActiveElement()
results.value = []
break

// rerun search and display results
case KeyCode.ENTER:
e.preventDefault()
resetActiveElement()
results.value = []
showDropdown.value = true
getResults()
break

default:
break
}
}
}

// select and dispatch event with selected item, cleanup ui
function emitSearchResult (result: RegSearchResult) {
query.value = result.name
resetDropdown()

setTimeout(() => { // timeout required to wait for dom before applying focus, nextTick not working
inputRef.value?.input.focus()
}, 0)

onSelect(result) // emit event
}

function handleInput (e: Event) {
query.value = (e.target as HTMLInputElement).value
if (query.value !== '') {
getResults()
showDropdown.value = true
} else {
resetDropdown()
}
}

return {
query,
loading,
showDropdown,
hasFocus,
results,
statusText,
error,
getResults,
keyupHandler,
handleInput,
emitSearchResult
}
}
3 changes: 2 additions & 1 deletion business-registry-dashboard/app/enums/business-state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export enum BusinessState {
ACTIVE = 'Active',
DRAFT = 'Draft'
DRAFT = 'Draft',
HISTORICAL = 'Historical',
}
Loading

0 comments on commit b79092c

Please sign in to comment.