diff --git a/core/components/atoms/dropdown/Dropdown.tsx b/core/components/atoms/dropdown/Dropdown.tsx index 57b560ef31..fb550be85e 100644 --- a/core/components/atoms/dropdown/Dropdown.tsx +++ b/core/components/atoms/dropdown/Dropdown.tsx @@ -21,6 +21,8 @@ type fetchOptionsFunction = (searchTerm: string) => Promise<{ options: OptionSchema[]; }>; +export type ErrorType = 'DEFAULT' | 'NO_RECORDS_FOUND' | 'FAILED_TO_FETCH'; + export type EventType = | 'select-option' | 'deselect-option' @@ -228,6 +230,7 @@ interface DropdownState { tempSelected: OptionSchema[]; previousSelected: OptionSchema[]; scrollIndex?: number; + errorType: ErrorType; } /** @@ -243,6 +246,21 @@ interface DropdownState { * * onChange: Called when selected options are updated. * - Uncontrolled Dropdown: * * onChange: Called when user `clicks on option` / `clicks on Clear, or Apply button`. + * 4. Default errorTemplate: + * + *
+ * (props) => {
+ *      const { errorType = 'DEFAULT' } = props;
+ *      const errorMessages = {
+ *        'FAILED\_TO\_FETCH': 'Failed to fetch data',
+ *        'NO\_RECORDS\_FOUND': 'No results found',
+ *        'DEFAULT': 'No results found'
+ *      }
+ *      return(
+ *        \{errorMessages[errorType]}\
+ *      );
+ * }
+ * 
*/ export class Dropdown extends React.Component { staticLimit: number; @@ -282,6 +300,7 @@ export class Dropdown extends React.Component { selected: _showSelectedItems(async, '', withCheckbox) ? selected : [], triggerLabel: this.updateTriggerLabel(selectedGroup, optionsLength), selectAll: getSelectAll(selectedGroup, optionsLength, disabledOptions.length), + errorType: 'DEFAULT', }; } @@ -384,7 +403,7 @@ export class Dropdown extends React.Component { }; updateOptions = (init: boolean, async?: boolean) => { - const { searchTerm, selectAll, tempSelected, previousSelected } = this.state; + const { searchTerm, selectAll, tempSelected, previousSelected, errorType } = this.state; let updatedAsync = async === undefined ? this.state.async : async; const { fetchOptions, withCheckbox, withSearch } = this.props; @@ -409,6 +428,7 @@ export class Dropdown extends React.Component { this.setState({ ...this.state, + errorType: fetchOptions && errorType !== 'NO_RECORDS_FOUND' ? 'FAILED_TO_FETCH' : errorType, scrollIndex: res.scrollToIndex || 0, optionsLength, loading: false, @@ -433,6 +453,7 @@ export class Dropdown extends React.Component { loading: true, searchInit: true, searchTerm: search, + errorType: 'NO_RECORDS_FOUND', }); }; @@ -633,6 +654,17 @@ export class Dropdown extends React.Component { ); }); + reload = () => { + this.setState( + { + loading: true, + }, + () => { + this.updateOptions(false); + } + ); + }; + debounceClear = debounce(250, () => this.updateOptions(false)); onClearOptions = () => { @@ -757,6 +789,7 @@ export class Dropdown extends React.Component { triggerLabel, previousSelected, scrollIndex, + errorType, } = this.state; const { withSelectAll = true, withCheckbox } = this.props; @@ -796,6 +829,8 @@ export class Dropdown extends React.Component { onSelectAll={this.onSelectAll} customTrigger={triggerOptions.customTrigger} scrollIndex={scrollIndex} + updateOptions={this.reload} + errorType={errorType} {...rest} /> ); diff --git a/core/components/atoms/dropdown/DropdownList.tsx b/core/components/atoms/dropdown/DropdownList.tsx index 4da1a92e01..2b46ee41fb 100644 --- a/core/components/atoms/dropdown/DropdownList.tsx +++ b/core/components/atoms/dropdown/DropdownList.tsx @@ -8,6 +8,8 @@ import classNames from 'classnames'; import Loading from './Loading'; import { BaseProps, extractBaseProps } from '@/utils/types'; import { ChangeEvent } from '@/common.type'; +import { ErrorTemplate } from './ErrorTemplate'; +import { ErrorType } from './Dropdown'; export type DropdownAlign = 'left' | 'right'; export type OptionType = 'DEFAULT' | 'WITH_ICON' | 'WITH_META' | 'ICON_WITH_META'; @@ -35,6 +37,10 @@ interface PopoverOptions { type TriggerAndOptionProps = TriggerProps & OptionRendererProps; +export interface ErrorTemplateProps { + errorType?: ErrorType; +} + export interface DropdownListProps extends TriggerAndOptionProps { /** * Aligns the `Dropdown` left/right @@ -43,9 +49,12 @@ export interface DropdownListProps extends TriggerAndOptionProps { align?: DropdownAlign; /** * Display message when there is no result - * @default "No result found" */ noResultMessage?: string; + /** + * Template to be rendered when **error: true** + */ + errorTemplate?: React.FunctionComponent; /** * Label of Select All checkbox * @default "Select All" @@ -182,6 +191,9 @@ interface OptionsProps extends DropdownListProps, BaseProps { onSearchChange?: (searchText: string) => void; onOptionSelect: (selected: any[] | any) => void; onSelect: (option: OptionSchema, checked: boolean) => void; + updateOptions: () => void; + errorType: ErrorType; + errorTemplate?: React.FunctionComponent; } export const usePrevious = (value: any) => { @@ -224,6 +236,10 @@ const DropdownList = (props: OptionsProps) => { className, searchPlaceholder = 'Search..', scrollIndex, + updateOptions, + noResultMessage, + errorType, + loadingOptions, } = props; const baseProps = extractBaseProps(props); @@ -244,6 +260,8 @@ const DropdownList = (props: OptionsProps) => { minHeight && setMinHeight(minHeight); }; + const isDropdownListBlank = listOptions.length === 0 && !loadingOptions && selected.length <= 0; + React.useEffect(() => { let timer: any; if (dropdownOpen) { @@ -256,7 +274,6 @@ const DropdownList = (props: OptionsProps) => { minWidth: minWidth ? minWidth : popperMinWidth, maxWidth: maxWidth ? maxWidth : '100%', }; - requestAnimationFrame(getMinHeight); setPopoverStyle(popperWrapperStyle); @@ -325,6 +342,16 @@ const DropdownList = (props: OptionsProps) => { minHeight: minHeight, }; + const defaultErrorTemplate = () => { + return ( + + ); + }; + const getDropdownSectionClass = (showClearButton?: boolean) => { return classNames({ ['Dropdown-section']: true, @@ -550,7 +577,7 @@ const DropdownList = (props: OptionsProps) => { selectedSectionLabel = 'Selected Items', allItemsSectionLabel = 'All Items', loadersCount = 10, - loadingOptions, + errorTemplate = defaultErrorTemplate, } = props; const selectAllPresent = _isSelectAllPresent(searchTerm, remainingOptions, withSelectAll, withCheckbox); @@ -566,15 +593,18 @@ const DropdownList = (props: OptionsProps) => { ); } - if (listOptions.length === 0 && !loadingOptions && selected.length <= 0) { - const { noResultMessage = 'No result found' } = props; - return ( -
-
-
{noResultMessage}
+ if (isDropdownListBlank) { + if (noResultMessage) { + return ( +
+
+
{noResultMessage}
+
-
- ); + ); + } else { + return errorTemplate && errorTemplate({ errorType }); + } } return ( @@ -689,6 +719,8 @@ const DropdownList = (props: OptionsProps) => { } }; + const enableSearch = (withSearch || props.async) && (!isDropdownListBlank || errorType === 'NO_RECORDS_FOUND'); + return ( //TODO(a11y) //eslint-disable-next-line @@ -703,7 +735,7 @@ const DropdownList = (props: OptionsProps) => { {...popoverOptions} data-test="DesignSystem-Dropdown--Popover" > - {(withSearch || props.async) && renderSearch()} + {enableSearch && renderSearch()} {renderDropdownSection()} {showApplyButton && withCheckbox && renderApplyButton()} diff --git a/core/components/atoms/dropdown/ErrorTemplate.tsx b/core/components/atoms/dropdown/ErrorTemplate.tsx new file mode 100644 index 0000000000..16e063f448 --- /dev/null +++ b/core/components/atoms/dropdown/ErrorTemplate.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { Text, Button } from '@/index'; +import { ErrorType } from './Dropdown'; + +interface ErrorTemplateProps { + dropdownStyle: React.CSSProperties; + errorType: ErrorType; + updateOptions: () => void; +} + +const errorTitle: Record = { + FAILED_TO_FETCH: 'Failed to fetch data', + NO_RECORDS_FOUND: 'No results found', + DEFAULT: 'No record available', +}; + +const errorDescription: Record = { + FAILED_TO_FETCH: "We couldn't load the data, try reloading.", + NO_RECORDS_FOUND: 'Try modifying your search to find what you are looking for.', + DEFAULT: 'We have nothing to show you at the moment.', +}; + +export const ErrorTemplate: React.FC = ({ dropdownStyle, errorType, updateOptions }) => { + return ( +
+
+ + {errorTitle[errorType]} + + + {errorDescription[errorType]} + + {errorType === 'FAILED_TO_FETCH' && ( + + )} +
+
+ ); +}; diff --git a/core/components/atoms/dropdown/__stories__/Options.tsx b/core/components/atoms/dropdown/__stories__/Options.tsx index 21d568ee05..9f855db5d7 100644 --- a/core/components/atoms/dropdown/__stories__/Options.tsx +++ b/core/components/atoms/dropdown/__stories__/Options.tsx @@ -224,3 +224,14 @@ export const fetchOptions = (searchTerm: string, options = dropdownOptions) => { }, 1000); }); }; + +export const fetchEmptyOptions = () => { + return new Promise((resolve) => { + window.setTimeout(() => { + resolve({ + options: [], + count: 0, + }); + }, 1000); + }); +}; diff --git a/core/components/atoms/dropdown/__stories__/variants/WithErrorTemplate.story.jsx b/core/components/atoms/dropdown/__stories__/variants/WithErrorTemplate.story.jsx new file mode 100644 index 0000000000..b65db29c36 --- /dev/null +++ b/core/components/atoms/dropdown/__stories__/variants/WithErrorTemplate.story.jsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import { Dropdown, Icon, Text } from '@/index'; +import { Uncontrolled, Controlled } from '../_common_/types'; + +// CSF format story +export const withErrorTemplate = () => { + const fetchOptions = (searchTerm) => { + return new Promise((resolve) => { + this.window.setTimeout(() => { + resolve({ + searchTerm, + options: [], + count: 0, + }); + }, 1000); + }); + }; + + const errorTemplate = () => { + return ( +
+
+ + + Failed to fetch data + + + We couldn't load the data, try reloading. + +
+
+ ); + }; + + return ; +}; + +const customCode = `() => { + const fetchOptions = (searchTerm) => { + return new Promise((resolve) => { + this.window.setTimeout(() => { + resolve({ + searchTerm, + options: [], + count: 0, + }); + }, 1000); + }); + }; + + const errorTemplate = (errorType) => { + console.log(errorType); + return ( +
+
+ + + Failed to fetch data + + + We couldn't load the data, try reloading. + +
+
+ ); + }; + + return ; +}`; + +export default { + title: 'Inputs/Dropdown/Variants/With Error Template', + component: Dropdown, + parameters: { + docs: { + docPage: { + customCode, + title: 'Dropdown', + props: { + components: { Uncontrolled, Controlled }, + exclude: ['showHead'], + }, + }, + }, + }, +}; diff --git a/core/components/atoms/dropdown/__tests__/Dropdown.test.tsx b/core/components/atoms/dropdown/__tests__/Dropdown.test.tsx index 46cb566c52..22f6fb05c5 100644 --- a/core/components/atoms/dropdown/__tests__/Dropdown.test.tsx +++ b/core/components/atoms/dropdown/__tests__/Dropdown.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { render, fireEvent, waitFor, screen } from '@testing-library/react'; import { testHelper, filterUndefined, valueHelper, testMessageHelper } from '@/utils/testHelper'; -import { Dropdown } from '@/index'; +import { Dropdown, Text } from '@/index'; import { DropdownProps as Props } from '@/index.type'; import { _isEqual } from '../utility'; import { @@ -14,6 +14,7 @@ import { fetchOptions, preSelectedOptions, allSelectedOptions, + fetchEmptyOptions, } from '../__stories__/Options'; const size = ['tiny', 'regular']; @@ -36,6 +37,8 @@ const trigger = 'DesignSystem-DropdownTrigger'; const keyDownEvents = ['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Default']; +const errorTemplate = () => Test Error Message.; + describe('Dropdown component', () => { const mapper: Record = { triggerSize: valueHelper(size, { required: true, iterate: true }), @@ -486,6 +489,29 @@ describe('Dropdown component with search', () => { fireEvent.click(dropdownTrigger); expect(screen.getByPlaceholderText('Custom search text')).toBeInTheDocument(); }); + + it('is not rendered when no record is available', () => { + const { getByTestId, queryByTestId } = render(); + const dropdownTrigger = getByTestId(trigger); + fireEvent.click(dropdownTrigger); + + expect(queryByTestId('DesignSystem-Input')).not.toBeInTheDocument(); + }); + + it('is rendered when search returns no result', async () => { + const { getByTestId, getAllByTestId } = render(); + + const dropdownTrigger = getByTestId(trigger); + fireEvent.click(dropdownTrigger); + + const searchInput = getByTestId('DesignSystem-Input'); + fireEvent.change(searchInput, { target: { value: 'Option 101' } }); + + await waitFor(() => { + expect(getAllByTestId('DesignSystem-Dropdown--errorWrapper')).toHaveLength(1); + expect(searchInput).toBeInTheDocument(); + }); + }); }); describe('Dropdown component', () => { @@ -729,3 +755,60 @@ describe('Dropdown component with all options selected', () => { }); }); }); + +describe('Dropdown errorTemplate', () => { + it('renders default template when no record is available', () => { + const { getByTestId, getAllByTestId } = render(); + + const dropdownTrigger = getByTestId(trigger); + fireEvent.click(dropdownTrigger); + + expect(getAllByTestId('DesignSystem-Text')[0].textContent).toMatch('No record available'); + expect(getAllByTestId('DesignSystem-Text')[1].textContent).toMatch('We have nothing to show you at the moment.'); + }); + + it('renders default template when promise fetches no result', async () => { + const { getByTestId, getAllByTestId } = render(); + + const dropdownTrigger = getByTestId(trigger); + fireEvent.click(dropdownTrigger); + + await waitFor(() => { + expect(getAllByTestId('DesignSystem-Text')[0].textContent).toMatch('Failed to fetch data'); + expect(getAllByTestId('DesignSystem-Text')[1].textContent).toMatch("We couldn't load the data, try reloading."); + }); + }); + + it('renders default template when search returns no result', async () => { + const { getByTestId, getAllByTestId } = render(); + + const dropdownTrigger = getByTestId(trigger); + fireEvent.click(dropdownTrigger); + + const searchInput = getByTestId('DesignSystem-Input'); + expect(searchInput).toBeInTheDocument(); + fireEvent.change(searchInput, { target: { value: 'Option 101' } }); + + await waitFor(() => { + expect(getAllByTestId('DesignSystem-Text')[0].textContent).toMatch('No results found'); + expect(getAllByTestId('DesignSystem-Text')[1].textContent).toMatch( + 'Try modifying your search to find what you are looking for.' + ); + }); + }); + + it('check: prop errorTemplate', async () => { + const { getByTestId } = render(); + + const dropdownTrigger = getByTestId(trigger); + fireEvent.click(dropdownTrigger); + + const searchInput = getByTestId('DesignSystem-Input'); + expect(searchInput).toBeInTheDocument(); + fireEvent.change(searchInput, { target: { value: 'Option 101' } }); + + await waitFor(() => { + expect(screen.getByText('Test Error Message.')).toBeInTheDocument(); + }); + }); +}); diff --git a/core/components/organisms/timePicker/__tests__/TimePickerWithSearch.test.tsx b/core/components/organisms/timePicker/__tests__/TimePickerWithSearch.test.tsx index 41d3d0113a..d9459778a6 100644 --- a/core/components/organisms/timePicker/__tests__/TimePickerWithSearch.test.tsx +++ b/core/components/organisms/timePicker/__tests__/TimePickerWithSearch.test.tsx @@ -293,7 +293,7 @@ describe('TimePicker Event Handlers', () => { describe('TimePicker Search Error Handlers', () => { it('check for five digit search query in 12 hour format', async () => { - const { getByTestId } = render(); + const { getByTestId } = render(); const dropdownTrigger = getByTestId(trigger); fireEvent.click(dropdownTrigger); @@ -308,7 +308,9 @@ describe('TimePicker Search Error Handlers', () => { }); it('check for invalid search', async () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); const dropdownTrigger = getByTestId(trigger); fireEvent.click(dropdownTrigger);