optimizely-oui
Version:
Optimizely's Component Library.
357 lines (322 loc) • 12.1 kB
JavaScript
import { debounce, noop } from "lodash";
import PropTypes from "prop-types";
import React from "react";
import { keyboardTracker } from "../../utils/accessibility";
import Input from "../Input";
const INITIAL_SEARCH_PAGE_SIZE = 5;
const INPUT_DEBOUNCE_PERIOD = 120;
class SearchPicker extends React.Component {
static propTypes = {
/**
* A render function that is invoked with the following info:
* availableEntities {Array<Entity>} - The list of entities available
* based on the current input. If no query term is
* entered, the default search collection is used.
*
* isLoading {Boolean} - Whether or not a search is in progress.
* renderInput {Function<props>} - A function to render the <Input>
* searchQuery {String} - The current search term
* currentFauxFocusIndex {Number} - Index of the item currently selected.
* resultsText {Object} - An object containing search result
* summaries based on the the types provided
* See this.getResultsText
*
*/
children: PropTypes.func.isRequired,
/**
* Which index in the list is currently selected.
* via @keyboardTracker
*/
currentFauxFocusIndex: PropTypes.number.isRequired,
/**
* This function can be set when custom search results filtering is required
* It will override the internal search results filtering
*
* @param {Array} searchResults
* @param {Array} selectedEntityIds
* @param {Array} filteredSearchResults
*/
filterAvailableEntities: PropTypes.func,
/**
* A handler for the input element event onKeyDown
* via @keyboardTracker
*/
handleKeyDown: PropTypes.func.isRequired,
/**
* The element ref to use for capturing keyboard input.
* via @keyboardTracker
*/
keyboardRef: PropTypes.shape({ current: PropTypes.elementType }),
/**
* Handler to call when an item is selected via the keyboard.
*/
onItemSelected: PropTypes.func.isRequired,
/**
* The function to invoke to execute an authenticated public API search.
* Different consumers will have different methods of authenticating.
*
* @param {Object} requestConfig
* @return {Promise}
*/
searchFunction: PropTypes.func.isRequired,
/**
* Additional options to pass to the search function when a search is
* executed. Type and query will be overridden by this component.
*/
searchOptions: PropTypes.object,
/**
* An optional list of ids which should be considered "selected".
* If provided, these ids will be filtered out of the list.
*/
selectedEntityIds: PropTypes.array,
/**
* Sets the number of items for the list index tracker.
* via @keyboardTracker
*/
setItemCount: PropTypes.func,
/**
* Sets the item select callback fn for the list index tracker.
* via @keyboardTracker
*/
setOnItemSelect: PropTypes.func,
/* The entity types we should query search for. */
supportedTypes: PropTypes.array.isRequired,
};
static defaultProps = {
searchOptions: {},
selectedEntityIds: [],
setItemCount: noop,
setOnItemSelect: noop,
};
/**
* Notify keyboardTracker what to do when ENTER is pressed on an item
* and kickoff the initial search to populate the default result set.
*/
componentDidMount = () => {
const { searchFunction, searchOptions, setOnItemSelect, supportedTypes } = this.props;
setOnItemSelect(() => this.handleItemSelected);
searchFunction({
...searchOptions,
types: supportedTypes,
query: "",
per_page: INITIAL_SEARCH_PAGE_SIZE,
})
.then(results => {
// Make this Promise catch if the results are not an array
this.verifyResultOrThrow(results);
this.setState({
defaultResults: results,
});
})
.finally(() => {
const { searchQuery } = this.state;
// Always set loading false as long as searchQuery is empty
if (!searchQuery) {
this.setState({
isLoading: false,
});
}
});
};
/**
* Whenever the component updates, notify the keyboardTracker
* of how many items are currently available. (this also resets
* the faux focus index to 0)
*/
componentDidUpdate = () => {
const items = this.getAvailableEntities();
this.props.setItemCount(items.length);
};
state = {
currentSearch: null,
defaultResults: [],
isLoading: true,
results: null,
searchQuery: "",
};
handleItemSelected = index => {
const { onItemSelected } = this.props;
const { searchQuery } = this.state;
onItemSelected(index, this.getAvailableEntities(), searchQuery);
};
/**
* Handles changes to the search term by calling the debounced change handler.
* If the term is an empty string, short circuit and reset the results.
* @param {React.SyntheticEvent} event - The onInput event
*/
handleOnInput = event => {
const { value } = event.target;
this.setState({
searchQuery: value,
});
if (value) {
this.handleOnInputDebounced(value);
} else {
const { currentSearch } = this.state;
if (currentSearch) {
currentSearch.reject("Search interrupted by empty search query.");
}
this.setState({
isLoading: false,
results: null,
currentSearch: null,
});
}
};
/**
* Execute the search function in a way that is cancellable by storing
* the Promise resolution methods in state.
* This enables subsequent invocations to cancel an in progress
* search in favor of a new one.
* @param {string} query - The term to search for.
* @returns {Promise<Array>}
*/
executeNewSearch = query =>
new Promise((resolve, reject) => {
const { searchFunction, searchOptions, supportedTypes } = this.props;
const { currentSearch } = this.state;
if (currentSearch) {
currentSearch.reject(`Search interrupted by new search query: ${query}`);
}
this.setState({
currentSearch: {
resolve,
reject,
},
isLoading: true,
});
searchFunction({
...searchOptions,
types: supportedTypes,
query: query.toLowerCase(),
}).then(resolve, reject);
});
/**
* A debounced method to invoke the search and store the result in state.
* @param {string} query - The term to search for.
*/
handleOnInputDebounced = debounce(
query => {
this.executeNewSearch(query).then(
results => {
const { searchQuery } = this.state;
this.setState({
isLoading: false,
currentSearch: null,
});
// Ensure the query wasn't killed while waiting for the result.
if (searchQuery) {
this.setState({
results,
});
}
},
e => {
/** Swallow rejections */
}
);
},
!!process.env.JEST_WORKER_ID ? 0 : INPUT_DEBOUNCE_PERIOD
);
/**
* Filter the current result set to exclude selected entities.
* @returns {Array<Object>}
*/
getAvailableEntities = () => {
const { selectedEntityIds, filterAvailableEntities } = this.props;
if (typeof filterAvailableEntities === "function") {
return filterAvailableEntities(this.getResultSet(), selectedEntityIds);
}
return this.getResultSet().filter(result => !selectedEntityIds.includes(result.id));
};
/**
* Helper method to return the current result set,
* falling back to the default.
* @returns {Array<Object>}
*/
getResultSet = () => {
const { results, defaultResults } = this.state;
return results || defaultResults;
};
/**
* Compute a human readable result text.
* @returns {Object}
*/
getResultsText = () => {
const { supportedTypes } = this.props;
const { isLoading, searchQuery } = this.state;
const resultCount = this.getAvailableEntities().length;
const summaryNoun = supportedTypes.length > 1 ? "entities" : `${supportedTypes[0]}s`;
let summary = `Recently created ${summaryNoun}`;
if (isLoading) {
summary = `Searching for "${summaryNoun}" matching "${searchQuery}"`;
} else if (searchQuery) {
if (resultCount) {
summary = `Found ${resultCount} ${summaryNoun} matching "${searchQuery}"`;
} else {
summary = `No ${summaryNoun} found matching "${searchQuery}"`;
}
}
return {
summary,
};
};
/**
* @description Provided an API response, an error will be thrown if the
* provided value is not an Array. This is used within a Promise and
* will only cause the Promise to catch instead of this component.
* @param {*} results - The response of the provided searchFunction, which
* is expected to be an Array.
*/
verifyResultOrThrow = results => {
if (!Array.isArray(results)) {
throw new Error("SearchPicker props.searchFunction resolved with a value that is not an Array.");
}
};
/**
* A render method passed to children so that they can render the search
* input component wherever they like.
* @param {Object} props - Any additional props to pass to the <Input>.
* @returns {React.Component}
*/
renderInput = (props = {}) => {
const { handleKeyDown, supportedTypes } = this.props;
const { searchQuery } = this.state;
const { handleOnInput } = this;
const placeholderNoun = supportedTypes.map(i => `${i}s`).join(",");
return (
<Input
isFilter={true}
onChange={handleOnInput}
placeholder={`Search for ${placeholderNoun}`}
testSection={`filter-${placeholderNoun}-input`}
type="search"
value={searchQuery}
onKeyDown={handleKeyDown}
{...props}
/>
);
};
/**
* A simple render method that passes relevant state and controls to the
* children.
* @returns {React.Component}
*/
render() {
const { children, currentFauxFocusIndex } = this.props;
const { searchQuery, isLoading } = this.state;
const { renderInput, handleOnInputDebounced } = this;
const availableEntities = this.getAvailableEntities();
return children({
availableEntities,
isLoading,
renderInput,
searchQuery,
makeSearchRequest: handleOnInputDebounced,
currentFauxFocusIndex,
resultsText: this.getResultsText(),
});
}
}
export default SearchPicker;