diff --git a/package-lock.json b/package-lock.json index 1dd29cd7e..8160ca358 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lob/ui-components", - "version": "2.0.89", + "version": "2.0.90", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@lob/ui-components", - "version": "2.0.89", + "version": "2.0.90", "dependencies": { "date-fns": "^2.29.3", "date-fns-holiday-us": "^0.3.1", diff --git a/package.json b/package.json index f46077ebc..317bf249d 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/AdvancedSearchBar/AdvancedSearchBar.mdx b/src/components/AdvancedSearchBar/AdvancedSearchBar.mdx new file mode 100644 index 000000000..9bcd145af --- /dev/null +++ b/src/components/AdvancedSearchBar/AdvancedSearchBar.mdx @@ -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. + + + +## 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 + + + + + +``` + +## Props + + diff --git a/src/components/AdvancedSearchBar/AdvancedSearchBar.stories.js b/src/components/AdvancedSearchBar/AdvancedSearchBar.stories.js new file mode 100644 index 000000000..dd555f468 --- /dev/null +++ b/src/components/AdvancedSearchBar/AdvancedSearchBar.stories.js @@ -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: ` + + + + + ` +}); + +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 +}; diff --git a/src/components/AdvancedSearchBar/AdvancedSearchBar.vue b/src/components/AdvancedSearchBar/AdvancedSearchBar.vue new file mode 100644 index 000000000..7a2f6a68a --- /dev/null +++ b/src/components/AdvancedSearchBar/AdvancedSearchBar.vue @@ -0,0 +1,178 @@ + + diff --git a/src/components/AdvancedSearchBar/__tests__/AdvancedSearchBar.spec.ts b/src/components/AdvancedSearchBar/__tests__/AdvancedSearchBar.spec.ts new file mode 100644 index 000000000..4e996c0f2 --- /dev/null +++ b/src/components/AdvancedSearchBar/__tests__/AdvancedSearchBar.spec.ts @@ -0,0 +1,185 @@ +import '@testing-library/jest-dom'; +import AdvancedSearchBar from '../AdvancedSearchBar.vue'; +import { translate } from '@/mixins'; +import { IconName } from '../../Icon'; +import { RenderOptions, render, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; + +const mixins = [translate]; + +const initialProps = { + searchFunction: (searchTerm: string) => { + const allResults = [ + { + title: 'Recipients', + icon: IconName.USER, + results: [ + { + name: 'John Doe', + description: 'A postcard to John Doe', + type: 'postcard' + }, + { + name: 'Jane Doe', + description: 'A postcard to Jane Doe', + type: 'postcard' + }, + { + name: 'John Smith', + description: 'soccer postcard', + 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', + description: 'A template to create postcard for John Doe' + }, + { + name: 'Template with Jane Doe', + description: 'A template to create postcard for Jane Doe' + } + ] + } + ]; + + const results = allResults.map((result) => { + return { + title: result.title, + icon: result.icon, + results: result.results.filter( + (eachResult) => + eachResult.description.includes(searchTerm) || + eachResult.name.includes(searchTerm) + ) + }; + }); + + return new Promise((resolve) => { + setTimeout(() => { + resolve(results); + }, 0); + }); + } +}; + +// Helper function to render the component +const renderComponent = (options: RenderOptions) => + render(AdvancedSearchBar, { ...options, global: { mixins } }); + +describe('AdvancedSearchBar', () => { + it('clears entered searchTerm when x button is clicked', async () => { + const searchTerm = 'something'; + const props = { + ...initialProps + }; + + const { getByLabelText, getByTestId } = renderComponent({ props }); + + const input = getByLabelText('Search term') as HTMLInputElement; + await userEvent.type(input, searchTerm); + expect(input.value).toBe(searchTerm); + + const button = getByTestId('clearSearchButton') as HTMLElement; + await userEvent.click(button); + expect(input.value).toBe(''); + }); + + it('does not show the clear button if there is no search term present', async () => { + const props = { + ...initialProps + }; + + const { queryByTestId } = renderComponent({ props }); + + const button = queryByTestId('clearSearchButton'); + expect(button).toHaveClass('opacity-0'); + expect(button).toBeDisabled(); + }); + + it('executes the search function when the user types', async () => { + const searchTerm = 'soccer'; + const props = { + ...initialProps, + data: await initialProps.searchFunction(searchTerm), + count: 1, + footer: true + }; + + const { getByLabelText, getByText } = renderComponent({ props }); + + const input = getByLabelText('Search term') as HTMLInputElement; + await userEvent.type(input, searchTerm); + expect(input.value).toBe(searchTerm); + + await waitFor(() => { + expect(getByText('1 matching results')).toBeInTheDocument(); + }); + }); + + it('hides the search results when the user clicks outside the search bar', async () => { + const searchTerm = 'soccer'; + const props = { + ...initialProps, + data: await initialProps.searchFunction(searchTerm), + count: 1 + }; + + const { queryByRole, getByLabelText, getByText } = renderComponent({ + props + }); + + const input = getByLabelText('Search term') as HTMLInputElement; + await userEvent.type(input, searchTerm); + expect(input.value).toBe(searchTerm); + + let searchResults = queryByRole('results'); + await waitFor(() => { + expect(getByText('1 matching results')).toBeInTheDocument(); + }); + await userEvent.click(document.body); + searchResults = queryByRole('results'); + await waitFor(() => { + expect(searchResults).not.toBeInTheDocument(); + }); + }); + + it('does not clear the input when the user clicks outside the search bar', async () => { + const searchTerm = 'soccer'; + const props = { + ...initialProps, + data: await initialProps.searchFunction(searchTerm), + count: 1 + }; + + const { getByLabelText, getByText } = renderComponent({ props }); + + const input = getByLabelText('Search term') as HTMLInputElement; + await userEvent.type(input, searchTerm); + expect(input.value).toBe(searchTerm); + + await waitFor(() => { + expect(getByText('1 matching results')).toBeInTheDocument(); + }); + await userEvent.click(document.body); + expect(input.value).toBe(searchTerm); + }); +}); diff --git a/src/components/AdvancedSearchBar/index.ts b/src/components/AdvancedSearchBar/index.ts new file mode 100644 index 000000000..e95c5c565 --- /dev/null +++ b/src/components/AdvancedSearchBar/index.ts @@ -0,0 +1 @@ +export { default as AdvancedSearchBar } from './AdvancedSearchBar.vue'; diff --git a/src/components/Icon/svgs/MagnifyingGlass.svg b/src/components/Icon/svgs/MagnifyingGlass.svg new file mode 100644 index 000000000..6da8880bd --- /dev/null +++ b/src/components/Icon/svgs/MagnifyingGlass.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/Icon/types.ts b/src/components/Icon/types.ts index 6d2dbf0a0..500433bb2 100644 --- a/src/components/Icon/types.ts +++ b/src/components/Icon/types.ts @@ -74,6 +74,7 @@ export const IconName = { LAYERS: 'Layers', LIGHTNING: 'Lightning', LOCATION_PIN: 'LocationPin', + MAGNIFYING_GLASS: 'MagnifyingGlass', MONEY_BILL: 'MoneyBill', MINUS: 'Minus', NAV_ARROW_LEFT: 'NavArrowLeft', diff --git a/src/components/index.js b/src/components/index.js index 762dd4545..9a105b14c 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,4 +1,5 @@ export { default as Accordion } from './Accordion/Accordion'; +export { default as AdvancedSearchBar } from './AdvancedSearchBar/AdvancedSearchBar'; export { default as Alert } from './Alert/Alert'; export { default as Breadcrumb } from './Breadcrumb/Breadcrumb'; export { default as LobButton } from './Button/Button'; diff --git a/src/mixins/en.js b/src/mixins/en.js index 416ff5cde..d2bbca937 100644 --- a/src/mixins/en.js +++ b/src/mixins/en.js @@ -46,7 +46,9 @@ export default { loading: 'Loading, please wait...', resultsPrefix: 'View all', resultsSuffix: 'results...', - noResults: 'No results found' + noResults: 'No results found', + matchingResults: 'matching results', + seeAllResults: 'See all results' }, dropzone: { yourFile: 'your file',