Skip to content

Commit

Permalink
[MI-400] Advanced Search with multi table view (#535)
Browse files Browse the repository at this point in the history
* new advanced search component
* add reactivity to search bar
  • Loading branch information
vmangwani authored Sep 3, 2024
1 parent 47a6506 commit 1647f0e
Show file tree
Hide file tree
Showing 11 changed files with 541 additions and 4 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lob/ui-components",
"version": "2.0.89",
"version": "2.0.90",
"engines": {
"node": ">=20.2.0",
"npm": ">=10.2.0"
Expand Down
37 changes: 37 additions & 0 deletions src/components/AdvancedSearchBar/AdvancedSearchBar.mdx
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 src/components/AdvancedSearchBar/AdvancedSearchBar.stories.js
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
};
178 changes: 178 additions & 0 deletions src/components/AdvancedSearchBar/AdvancedSearchBar.vue
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>
Loading

0 comments on commit 1647f0e

Please sign in to comment.