-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[MI-400] Advanced Search with multi table view (#535)
* new advanced search component * add reactivity to search bar
- Loading branch information
Showing
11 changed files
with
541 additions
and
4 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
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
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,37 @@ | ||
import { Canvas, ArgTypes, PRIMARY_STORY } from '@storybook/addon-docs'; | ||
import { Primary } from './AdvancedSearchBar.stories'; | ||
|
||
# AdvancedSearchBar | ||
|
||
This Advanced Search Bar component is designed to render an input for the user to enter their search term and then return multi tabled search from Recipients, Campaigns and Templates. | ||
|
||
<Canvas of={Primary} /> | ||
|
||
## How to Use | ||
|
||
To use this component, you must provide a `searchFunction` to fetch search results. The function must return an array. The array must contain objects for each result group with nested results within it. | ||
|
||
The component also has 3 optional props. Count an option to display the a number of search results that differs from the number rendered. Link a link to see all the search results. Footer an option to display the footer for the search results. | ||
|
||
The table has a 2 slots, Header slot for you to define the header for each result group and body how to render a single result row which it will use to iterate and render each result. You can provide whatever markup you want to be rendered within the slots. | ||
|
||
To use this component, here is an example | ||
|
||
```html | ||
|
||
<AdvancedSearchBar searchFunction="myCustomSearch"> | ||
<template #header="{result} class="min-w-full"> | ||
<Icon v-if="result.icon" class="mr-1" :icon="result.icon"/> | ||
{{ result.title }} | ||
</template> | ||
<template #body="{ result }"> | ||
<div v-if="result"> | ||
{{ result.name }} | ||
</div> | ||
</template> | ||
</AdvancedSearchBar> | ||
``` | ||
## Props | ||
<ArgTypes story={PRIMARY_STORY} /> |
129 changes: 129 additions & 0 deletions
129
src/components/AdvancedSearchBar/AdvancedSearchBar.stories.js
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,129 @@ | ||
import { AdvancedSearchBar } from '@/components'; | ||
import mdx from './AdvancedSearchBar.mdx'; | ||
import routeDecorator, { | ||
routeTemplate | ||
} from '../../../.storybook/routeDecorator'; | ||
import { Icon, IconName } from '../Icon'; | ||
|
||
export default { | ||
title: 'Components/Advanced Search Bar', | ||
component: AdvancedSearchBar, | ||
decorators: [ | ||
routeDecorator('/', [ | ||
{ | ||
path: '/advanced-search', | ||
component: { | ||
template: routeTemplate('advanced-search') | ||
} | ||
} | ||
]) | ||
], | ||
parameters: { | ||
docs: { | ||
page: mdx | ||
} | ||
} | ||
}; | ||
|
||
const PrimaryTemplate = (args, { argTypes }) => ({ | ||
props: Object.keys(argTypes), | ||
components: { Icon, AdvancedSearchBar }, | ||
setup: () => ({ args }), | ||
template: ` | ||
<AdvancedSearchBar v-bind='args'> | ||
<template #header="{result}"> | ||
<div class="flex text-gray-400 text-xs items-center" v-if="result"> | ||
<Icon v-if="result.icon" class="mr-1" :icon="result.icon"/> | ||
{{ result.title }} | ||
</div> | ||
</template> | ||
<template #body="{ result }"> | ||
<div | ||
:result="result" | ||
class="min-w-full text-gray-700 text-sm font-medium hover:text-primary-700 flex" | ||
> | ||
<div v-if="result"> | ||
{{ result.name }} | ||
</div> | ||
</div> | ||
</template> | ||
</AdvancedSearchBar> | ||
` | ||
}); | ||
|
||
export const Primary = PrimaryTemplate.bind({}); | ||
Primary.args = { | ||
searchFunction: (searchTerm) => { | ||
const allResults = [ | ||
{ | ||
title: 'Recipients', | ||
icon: IconName.USER, | ||
results: [ | ||
{ | ||
name: 'John Doe', | ||
description: 'A postcard to John Doe for Texas', | ||
type: 'postcard' | ||
}, | ||
{ | ||
name: 'Jane Doe', | ||
description: 'A postcard to Jane Doe for California', | ||
type: 'postcard' | ||
}, | ||
{ | ||
name: 'John Smith', | ||
description: 'soccer postcard going to Texas', | ||
type: 'postcard' | ||
} | ||
] | ||
}, | ||
{ | ||
title: 'Campaigns', | ||
icon: IconName.BULLHORN, | ||
results: [ | ||
{ | ||
name: 'Marketing Campaign for Texas', | ||
description: '5000 recipients' | ||
}, | ||
{ | ||
name: 'Marketing Campaign for California', | ||
description: '10000 recipients' | ||
} | ||
] | ||
}, | ||
{ | ||
title: 'Templates', | ||
icon: IconName.CREATIVE, | ||
results: [ | ||
{ | ||
name: 'Template with John Doe to be sent to Texas', | ||
description: 'A template to create postcard for John Doe' | ||
}, | ||
{ | ||
name: 'Template with Jane Doe to be sent to California', | ||
description: 'A template to create postcard for Jane Doe' | ||
} | ||
] | ||
} | ||
]; | ||
const results = allResults.map((result) => { | ||
return { | ||
title: result.title, | ||
icon: result.icon, | ||
items: result.results.filter( | ||
(eachResult) => | ||
eachResult.description.includes(searchTerm) || | ||
eachResult.name.includes(searchTerm) | ||
) | ||
}; | ||
}); | ||
|
||
return new Promise((resolve) => { | ||
setTimeout(() => { | ||
resolve(results); | ||
}, 1500); // waits for 1500ms before returning results, so it's more 'realistic' | ||
}); | ||
}, | ||
link: '/advanced-search', | ||
count: 10, | ||
footer: true | ||
}; |
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,178 @@ | ||
<template> | ||
<div ref="searchBar" class="relative"> | ||
<TextInput | ||
id="searchBar" | ||
v-model="searchTerm" | ||
class="min-w-full" | ||
data-testid="searchBar" | ||
:label="t('search.textLabel')" | ||
:sr-only-label="true" | ||
@focus="visible = true" | ||
> | ||
<template #iconLeft> | ||
<Icon icon="MagnifyingGlass" /> | ||
</template> | ||
<template #iconRight> | ||
<button | ||
:class="[ | ||
'block', | ||
searchTerm ? 'opacity-100 cursor-pointer' : 'opacity-0' | ||
]" | ||
:aria-label="t('search.closeLabel')" | ||
:disabled="disabled" | ||
data-testid="clearSearchButton" | ||
@click="clearSearch" | ||
> | ||
<Icon icon="Close" /> | ||
</button> | ||
</template> | ||
</TextInput> | ||
<div | ||
v-if="visible || searchTerm.value" | ||
class="bg-white shadow overflow-y-auto min-w-full absolute" | ||
role="results" | ||
> | ||
<!-- If search is still running do not perform any action and show a searching bar --> | ||
<template v-if="searchTerm && searching"> | ||
{{ t('search.loading') }} | ||
</template> | ||
<!-- If search is done and seach has results, show the results --> | ||
<template v-else-if="!searching && props.data"> | ||
<div class="search-body m-2 border-b"> | ||
<div v-for="itemGroup in props.data" :key="itemGroup.id"> | ||
<LobTable v-if="itemGroup" class="min-w-full mb-4" space="sm"> | ||
<TableHeader> | ||
<slot name="header" :result="itemGroup" /> | ||
</TableHeader> | ||
<TableBody> | ||
<TableRow | ||
v-for="item in itemGroup?.items" | ||
:key="item" | ||
class="text-gray-500 hover:text-primary-700 cursor-pointer" | ||
@click="hide" | ||
> | ||
<slot name="body" :result="item" /> | ||
</TableRow> | ||
</TableBody> | ||
</LobTable> | ||
</div> | ||
</div> | ||
<!-- If search has results and user wants to show a footer, the total number of results --> | ||
<div | ||
v-if="searchTerm && footer" | ||
class="search-footer m-2 flow-root text-sm text-gray-700" | ||
> | ||
<div class="float-left"> | ||
{{ totalResults }} {{ t('search.matchingResults') }} | ||
</div> | ||
<div class="float-right flex"> | ||
<LobLink | ||
:to="link" | ||
:underline="false" | ||
class="text-sm hover:text-primary-900" | ||
@click="hide" | ||
> | ||
{{ t('search.seeAllResults') }} | ||
<Icon class="inline" :icon="IconName.NEXT" /> | ||
</LobLink> | ||
</div> | ||
</div> | ||
</template> | ||
<!-- If no results are found show a no results message --> | ||
<template v-else> | ||
{{ t('search.noResults') }} | ||
</template> | ||
</div> | ||
</div> | ||
</template> | ||
<script setup lang="ts"> | ||
import TextInput from '../TextInput/TextInput.vue'; | ||
import LobTable from '../Table/Table.vue'; | ||
import TableHeader from '../Table/TableHeader.vue'; | ||
import TableBody from '../Table/TableBody.vue'; | ||
import TableRow from '../Table/TableRow.vue'; | ||
import LobLink from '../Link/Link.vue'; | ||
import { ref, Ref, watch, computed, onMounted, onUnmounted } from 'vue'; | ||
import { Icon, IconName } from '../Icon'; | ||
const searchTerm = ref(''); | ||
const searching = ref(false); | ||
const visible = ref(false); | ||
const timeout = ref<number | null>(null); | ||
const searchBar = ref<null | HTMLDivElement>(null); | ||
const props = withDefaults( | ||
defineProps<{ | ||
searchFunction: Function; | ||
//receive the data as a prop. This allows for reactivity. | ||
data: Ref<any[]>; | ||
count?: number; | ||
link?: string; | ||
footer?: boolean; | ||
}>(), | ||
{ | ||
count: 0, | ||
link: '', | ||
footer: true | ||
} | ||
); | ||
const disabled = computed(() => !searchTerm.value); | ||
const totalResults = computed(() => props.count); | ||
async function search(searchTerm: string): Promise<void> { | ||
await props.searchFunction(searchTerm); | ||
} | ||
function debounceSearch(searchTerm: string, delayMs: number = 50) { | ||
if (timeout.value !== null) { | ||
clearTimeout(timeout.value); | ||
} | ||
timeout.value = setTimeout(async () => { | ||
search(searchTerm); | ||
}, delayMs) as unknown as number; | ||
} | ||
function hide() { | ||
visible.value = false; | ||
} | ||
function clearSearch() { | ||
//clear search term and fire and forget new search. | ||
searchTerm.value = ''; | ||
search(searchTerm.value); | ||
hide(); | ||
} | ||
//watch the search term for any update and search. | ||
watch(searchTerm, (newSearchTerm) => { | ||
if (newSearchTerm && newSearchTerm !== '') { | ||
visible.value = true; | ||
debounceSearch(newSearchTerm); | ||
} else { | ||
clearSearch(); | ||
} | ||
}); | ||
function onClickOutside($event: MouseEvent) { | ||
if (searchBar.value) { | ||
const clickOnTheContainer = searchBar.value === $event.target; | ||
const clickOnChild = | ||
searchBar.value && searchBar.value.contains($event.target as Node); | ||
if (!clickOnTheContainer && !clickOnChild) { | ||
hide(); | ||
} | ||
} else { | ||
clearSearch(); | ||
} | ||
} | ||
onMounted(() => { | ||
window.addEventListener('click', onClickOutside); | ||
}); | ||
onUnmounted(() => { | ||
window.removeEventListener('click', onClickOutside); | ||
}); | ||
</script> |
Oops, something went wrong.