-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #9 from deetz99/reg-search
BRD - UI: Reg search
- Loading branch information
Showing
12 changed files
with
643 additions
and
34 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
89
business-registry-dashboard/app/components/AsyncComboBox/index.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
243
business-registry-dashboard/app/composables/useComboBox.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
} |
Oops, something went wrong.