diff --git a/packages/core/src/components/tag-input/tagInput.tsx b/packages/core/src/components/tag-input/tagInput.tsx index 6bba2a64a2..f13a7f8852 100644 --- a/packages/core/src/components/tag-input/tagInput.tsx +++ b/packages/core/src/components/tag-input/tagInput.tsx @@ -15,6 +15,16 @@ import * as Utils from "../../common/utils"; import { Icon, IconName } from "../icon/icon"; import { ITagProps, Tag } from "../tag/tag"; +/** + * The method in which a `TagInput` value was added. + * - `"default"` - indicates that a value was added by manual selection. + * - `"blur"` - indicates that a value was added when the `TagInput` lost focus. + * This is only possible when `addOnBlur=true`. + * - `"paste"` - indicates that a value was added via paste. This is only + * possible when `addOnPaste=true`. + */ +export type TagInputAddMethod = "default" | "blur" | "paste"; + export interface ITagInputProps extends IIntentProps, IProps { /** * If true, `onAdd` will be invoked when the input loses focus. @@ -74,7 +84,7 @@ export interface ITagInputProps extends IIntentProps, IProps { * returns `false`. This is useful if the provided `value` is somehow invalid and should * not be added as a tag. */ - onAdd?: (values: string[]) => boolean | void; + onAdd?: (values: string[], method: TagInputAddMethod) => boolean | void; /** * Callback invoked when new tags are added or removed. Receives the updated list of `values`: @@ -260,10 +270,10 @@ export class TagInput extends AbstractPureComponent { + private addTags = (value: string, method: TagInputAddMethod = "default") => { const { inputValue, onAdd, onChange, values } = this.props; const newValues = this.getValues(value); - let shouldClearInput = Utils.safeInvoke(onAdd, newValues) !== false && inputValue === undefined; + let shouldClearInput = Utils.safeInvoke(onAdd, newValues, method) !== false && inputValue === undefined; // avoid a potentially expensive computation if this prop is omitted if (Utils.isFunction(onChange)) { shouldClearInput = onChange([...values, ...newValues]) !== false && shouldClearInput; @@ -342,7 +352,7 @@ export class TagInput extends AbstractPureComponent 0) { - this.addTags(this.state.inputValue); + this.addTags(this.state.inputValue, "blur"); } this.setState({ activeIndex: NONE, isInputFocused: false }); } @@ -367,7 +377,7 @@ export class TagInput extends AbstractPureComponent 0) { - this.addTags(value); + this.addTags(value, "default"); } else if (selectionEnd === 0 && this.props.values.length > 0) { // cursor at beginning of input allows interaction with tags. // use selectionEnd to verify cursor position and no text selection. @@ -405,7 +415,7 @@ export class TagInput extends AbstractPureComponent) => { diff --git a/packages/core/test/tag-input/tagInputTests.tsx b/packages/core/test/tag-input/tagInputTests.tsx index 3efadb335f..bf3f8df112 100644 --- a/packages/core/test/tag-input/tagInputTests.tsx +++ b/packages/core/test/tag-input/tagInputTests.tsx @@ -128,6 +128,7 @@ describe("", () => { pressEnterInInput(wrapper, NEW_VALUE); assert.isTrue(onAdd.calledOnce); assert.deepEqual(onAdd.args[0][0], [NEW_VALUE]); + assert.deepEqual(onAdd.args[0][1], "default"); }); it("is invoked on blur when addOnBlur=true", done => { @@ -140,6 +141,8 @@ describe("", () => { // Need setTimeout here to wait for focus to change after blur event setTimeout(() => { assert.isTrue(onAdd.calledOnce); + assert.deepEqual(onAdd.args[0][0], [NEW_VALUE]); + assert.equal(onAdd.args[0][1], "blur"); done(); }); }); @@ -183,6 +186,7 @@ describe("", () => { wrapper.find("input").simulate("paste", { clipboardData: { getData: () => text } }); assert.isTrue(onAdd.calledOnce); assert.deepEqual(onAdd.args[0][0], ["pasted"]); + assert.equal(onAdd.args[0][1], "paste"); }); it("is not invoked on paste if the text does not include a delimiter", () => { diff --git a/packages/docs-app/src/examples/select-examples/films.tsx b/packages/docs-app/src/examples/select-examples/films.tsx index 04723365f2..59bac3a415 100644 --- a/packages/docs-app/src/examples/select-examples/films.tsx +++ b/packages/docs-app/src/examples/select-examples/films.tsx @@ -152,8 +152,15 @@ export const renderCreateFilmOption = ( /> ); -export const filterFilm: ItemPredicate = (query, film) => { - return `${film.rank}. ${film.title.toLowerCase()} ${film.year}`.indexOf(query.toLowerCase()) >= 0; +export const filterFilm: ItemPredicate = (query, film, _index, exactMatch) => { + const normalizedTitle = film.title.toLowerCase(); + const normalizedQuery = query.toLowerCase(); + + if (exactMatch) { + return normalizedTitle === normalizedQuery; + } else { + return `${film.rank}. ${normalizedTitle} ${film.year}`.indexOf(normalizedQuery) >= 0; + } }; function highlightText(text: string, query: string) { @@ -210,6 +217,10 @@ export function areFilmsEqual(filmA: IFilm, filmB: IFilm) { return filmA.title.toLowerCase() === filmB.title.toLowerCase(); } +export function doesFilmEqualQuery(film: IFilm, query: string) { + return film.title.toLowerCase() === query.toLowerCase(); +} + export function arrayContainsFilm(films: IFilm[], filmToFind: IFilm): boolean { return films.some((film: IFilm) => film.title === filmToFind.title); } diff --git a/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx b/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx index 8f138d3cb7..ab1f739416 100644 --- a/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx +++ b/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx @@ -93,6 +93,7 @@ export class MultiSelectExample extends React.PureComponent} onItemSelect={this.handleFilmSelect} + onItemsPaste={this.handleFilmsPaste} popoverProps={{ minimal: popoverMinimal }} tagRenderer={this.renderTag} tagInputProps={{ tagProps: getTagProps, onRemove: this.handleTagRemove, rightElement: clearButton }} @@ -180,20 +181,29 @@ export class MultiSelectExample extends React.PureComponent { + const results = maybeAddCreatedFilmToArrays(nextItems, nextCreatedItems, film); + nextItems = results.items; + nextCreatedItems = results.createdItems; // Avoid re-creating an item that is already selected (the "Create // Item" option will be shown even if it matches an already selected // item). - films: !arrayContainsFilm(films, film) ? [...films, film] : films, + nextFilms = !arrayContainsFilm(nextFilms, film) ? [...nextFilms, film] : nextFilms; + }); + + this.setState({ + createdItems: nextCreatedItems, + films: nextFilms, items: nextItems, }); } @@ -224,6 +234,12 @@ export class MultiSelectExample extends React.PureComponent { + // On paste, don't bother with deselecting already selected values, just + // add the new ones. + this.selectFilms(films); + }; + private handleSwitchChange(prop: keyof IMultiSelectExampleState) { return (event: React.FormEvent) => { const checked = event.currentTarget.checked; diff --git a/packages/select/src/common/listItemsProps.ts b/packages/select/src/common/listItemsProps.ts index 555ebe7580..f05df65833 100644 --- a/packages/select/src/common/listItemsProps.ts +++ b/packages/select/src/common/listItemsProps.ts @@ -66,11 +66,21 @@ export interface IListItemsProps extends IProps { itemListPredicate?: ItemListPredicate; /** - * Customize querying of individual items. Return `true` to keep the item, `false` to hide. - * This method will be invoked once for each item, so it should be performant. For more complex - * queries, use `itemListPredicate` to operate once on the entire array. + * Customize querying of individual items. * - * This prop is ignored if `itemListPredicate` is also defined. + * __Filtering a list of items.__ This function is invoked to filter the + * list of items as a query is typed. Return `true` to keep the item, or + * `false` to hide. This method is invoked once for each item, so it should + * be performant. For more complex queries, use `itemListPredicate` to + * operate once on the entire array. For the purposes of filtering the list, + * this prop is ignored if `itemListPredicate` is also defined. + * + * __Matching a pasted value to an item.__ This function is also invoked to + * match a pasted value to an existing item if possible. In this case, the + * function will receive `exactMatch=true`, and the function should return + * true only if the item _exactly_ matches the query. For the purposes of + * matching pasted values, this prop will be invoked even if + * `itemListPredicate` is defined. */ itemPredicate?: ItemPredicate; @@ -130,6 +140,11 @@ export interface IListItemsProps extends IProps { */ onItemSelect: (item: T, event?: React.SyntheticEvent) => void; + /** + * Callback invoked when multiple items are selected at once via pasting. + */ + onItemsPaste?: (items: T[]) => void; + /** * Callback invoked when the query string changes. */ diff --git a/packages/select/src/common/predicate.ts b/packages/select/src/common/predicate.ts index 6fd90d79bc..473149894a 100644 --- a/packages/select/src/common/predicate.ts +++ b/packages/select/src/common/predicate.ts @@ -5,19 +5,13 @@ */ /** - * Customize querying of entire `items` array. Return new list of items. - * This method can reorder, add, or remove items at will. - * (Supports filter algorithms that operate on the entire set, rather than individual items.) - * - * If defined with `itemPredicate`, this prop takes priority and the other will be ignored. + * A custom predicate for returning an entirely new `items` array based on the provided query. + * See usage sites in `IListItemsProps`. */ export type ItemListPredicate = (query: string, items: T[]) => T[]; /** - * Customize querying of individual items. Return `true` to keep the item, `false` to hide. - * This method will be invoked once for each item, so it should be performant. For more complex - * queries, use `itemListPredicate` to operate once on the entire array. - * - * If defined with `itemListPredicate`, this prop will be ignored. + * A custom predicate for filtering items based on the provided query. + * See usage sites in `IListItemsProps`. */ -export type ItemPredicate = (query: string, item: T, index?: number) => boolean; +export type ItemPredicate = (query: string, item: T, index?: number, exactMatch?: boolean) => boolean; diff --git a/packages/select/src/components/query-list/queryList.tsx b/packages/select/src/components/query-list/queryList.tsx index cf4397b615..e24583c48e 100644 --- a/packages/select/src/components/query-list/queryList.tsx +++ b/packages/select/src/components/query-list/queryList.tsx @@ -54,6 +54,24 @@ export interface IQueryListRendererProps // Omit `createNewItem`, because it */ handleItemSelect: (item: T, event?: React.SyntheticEvent) => void; + /** + * Handler that should be invoked when the user pastes one or more values. + * + * This callback will use `itemPredicate` with `exactMatch=true` to find a + * subset of `items` exactly matching the pasted `values` provided, then it + * will invoke `onItemsPaste` with those found items. Each pasted value that + * does not exactly match an item will be ignored. + * + * If creating items is enabled (by providing both `createNewItemFromQuery` + * and `createNewItemRenderer`), then pasted values that do not exactly + * match an existing item will emit a new item as created via + * `createNewItemFromQuery`. + * + * If `itemPredicate` returns multiple matching items for a particular query + * in `queries`, then only the first matching item will be emitted. + */ + handlePaste: (queries: string[]) => void; + /** * Keyboard handler for up/down arrow keys to shift the active item. * Attach this handler to any element that should support this interaction. @@ -152,6 +170,7 @@ export class QueryList extends React.Component, IQueryList handleItemSelect: this.handleItemSelect, handleKeyDown: this.handleKeyDown, handleKeyUp: this.handleKeyUp, + handlePaste: this.handlePaste, handleQueryChange: this.handleQueryChange, itemList: itemListRenderer({ ...spreadableState, @@ -350,6 +369,47 @@ export class QueryList extends React.Component, IQueryList } }; + private handlePaste = (queries: string[]) => { + const { createNewItemFromQuery, onItemsPaste } = this.props; + + let nextActiveItem: T | undefined; + const nextQueries = []; + + // Find an exising item that exactly matches each pasted value, or + // create a new item if possible. Ignore unmatched values if creating + // items is disabled. + const pastedItemsToEmit = []; + + for (const query of queries) { + const equalItem = getMatchingItem(query, this.props); + + if (equalItem !== undefined) { + nextActiveItem = equalItem; + pastedItemsToEmit.push(equalItem); + } else if (this.canCreateItems()) { + const newItem = Utils.safeInvoke(createNewItemFromQuery, query); + if (newItem !== undefined) { + pastedItemsToEmit.push(newItem); + } + } else { + nextQueries.push(query); + } + } + + // UX nicety: combine all unmatched queries into a single + // comma-separated query in the input, so we don't lose any information. + // And don't reset the active item; we'll do that ourselves below. + this.setQuery(nextQueries.join(", "), false); + + // UX nicety: update the active item if we matched with at least one + // existing item. + if (nextActiveItem !== undefined) { + this.setActiveItem(nextActiveItem); + } + + Utils.safeInvoke(onItemsPaste, pastedItemsToEmit); + }; + private handleKeyDown = (event: React.KeyboardEvent) => { const { keyCode } = event; if (keyCode === Keys.ARROW_UP || keyCode === Keys.ARROW_DOWN) { @@ -418,10 +478,8 @@ export class QueryList extends React.Component, IQueryList } private isCreateItemRendered(): boolean { - const { createNewItemFromQuery } = this.props; return ( - createNewItemFromQuery != null && - this.props.createNewItemRenderer != null && + this.canCreateItems() && this.state.query !== "" && // this check is unfortunately O(N) on the number of items, but // alas, hiding the "Create Item" option when it exactly matches an @@ -430,6 +488,10 @@ export class QueryList extends React.Component, IQueryList ); } + private canCreateItems(): boolean { + return this.props.createNewItemFromQuery != null && this.props.createNewItemRenderer != null; + } + private wouldCreatedItemMatchSomeExistingItem() { // search only the filtered items, not the full items list, because we // only need to check items that match the current query. @@ -443,6 +505,20 @@ function pxToNumber(value: string | null) { return value == null ? 0 : parseInt(value.slice(0, -2), 10); } +function getMatchingItem(query: string, { items, itemPredicate }: IQueryListProps): T | undefined { + if (Utils.isFunction(itemPredicate)) { + // .find() doesn't exist in ES5. Alternative: use a for loop instead of + // .filter() so that we can return as soon as we find the first match. + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (itemPredicate(query, item, i, true)) { + return item; + } + } + } + return undefined; +} + function getFilteredItems(query: string, { items, itemPredicate, itemListPredicate }: IQueryListProps) { if (Utils.isFunction(itemListPredicate)) { // note that implementations can reorder the items here diff --git a/packages/select/src/components/select/multiSelect.tsx b/packages/select/src/components/select/multiSelect.tsx index c04ed6eb12..15452b676f 100644 --- a/packages/select/src/components/select/multiSelect.tsx +++ b/packages/select/src/components/select/multiSelect.tsx @@ -14,6 +14,7 @@ import { Popover, Position, TagInput, + TagInputAddMethod, Utils, } from "@blueprintjs/core"; import { Classes, IListItemsProps } from "../../common"; @@ -92,7 +93,13 @@ export class MultiSelect extends React.PureComponent, IM private renderQueryList = (listProps: IQueryListRendererProps) => { const { tagInputProps = {}, popoverProps = {}, selectedItems = [], placeholder } = this.props; - const { handleKeyDown, handleKeyUp } = listProps; + const { handlePaste, handleKeyDown, handleKeyUp } = listProps; + + const handleTagInputAdd = (values: any[], method: TagInputAddMethod) => { + if (method === "paste") { + handlePaste(values); + } + }; return ( extends React.PureComponent, IM className={classNames(Classes.MULTISELECT, tagInputProps.className)} inputRef={this.refHandlers.input} inputValue={listProps.query} + onAdd={handleTagInputAdd} onInputChange={listProps.handleQueryChange} values={selectedItems.map(this.props.tagRenderer)} /> diff --git a/packages/select/test/queryListTests.tsx b/packages/select/test/queryListTests.tsx index bff202fe01..26cd0ba8ea 100644 --- a/packages/select/test/queryListTests.tsx +++ b/packages/select/test/queryListTests.tsx @@ -11,7 +11,14 @@ import * as sinon from "sinon"; // this is an awkward import across the monorepo, but we'd rather not introduce a cyclical dependency or create another package import { IQueryListProps } from "@blueprintjs/select"; import { IFilm, renderFilm, TOP_100_FILMS } from "../../docs-app/src/examples/select-examples/films"; -import { IQueryListRendererProps, IQueryListState, ItemListPredicate, ItemListRenderer, QueryList } from "../src/index"; +import { + IQueryListRendererProps, + IQueryListState, + ItemListPredicate, + ItemListRenderer, + ItemPredicate, + QueryList, +} from "../src/index"; type FilmQueryListWrapper = ReactWrapper, IQueryListState>; @@ -158,4 +165,121 @@ describe("", () => { describe("scrolling", () => { it("brings active item into view"); }); + + describe("pasting", () => { + const onItemsPaste = sinon.spy(); + + const itemPredicate: ItemPredicate = (query: string, film: IFilm, _i?: number, exactMatch?: boolean) => { + return exactMatch === true ? query.toLowerCase() === film.title.toLowerCase() : true; + }; + + function mountForPasteTest(overrideProps: Partial> = {}) { + // Placeholder. This will be overwritten by the mounted component. + let handlePaste: (queries: string[]) => void; + + const props: IQueryListProps = { + ...testProps, + itemPredicate, + onItemsPaste, + renderer: sinon.spy((listItemsProps: IQueryListRendererProps) => { + handlePaste = listItemsProps.handlePaste; + return testProps.renderer(listItemsProps); + }), + ...overrideProps, + }; + + const filmQueryList: FilmQueryListWrapper = mount(); + // `handlePaste` will have been set by now, because `props.renderer` + // will have been called. + return { filmQueryList, handlePaste: handlePaste! }; + } + + afterEach(() => { + onItemsPaste.resetHistory(); + }); + + it("converts 1 pasted value into an item", () => { + const { filmQueryList, handlePaste } = mountForPasteTest(); + + const pastedValue = TOP_100_FILMS[0].title; + handlePaste([pastedValue]); + + assert.isTrue(onItemsPaste.calledOnce); + assert.deepEqual(onItemsPaste.args[0][0], [TOP_100_FILMS[0]]); + assert.deepEqual(filmQueryList.state().activeItem, TOP_100_FILMS[0]); + assert.deepEqual(filmQueryList.state().query, ""); + }); + + it("convert multiple pasted values into items", () => { + const { filmQueryList, handlePaste } = mountForPasteTest(); + + // Paste items in unsorted order for fun. + const item1 = TOP_100_FILMS[6]; + const item2 = TOP_100_FILMS[0]; + const item3 = TOP_100_FILMS[3]; + + const pastedValue1 = item1.title; + const pastedValue2 = item2.title; + const pastedValue3 = item3.title; + + handlePaste([pastedValue1, pastedValue2, pastedValue3]); + + assert.isTrue(onItemsPaste.calledOnce); + // Emits all three items. + assert.deepEqual(onItemsPaste.args[0][0], [item1, item2, item3]); + // Highlight the last item pasted. + assert.deepEqual(filmQueryList.state().activeItem, item3); + assert.deepEqual(filmQueryList.state().query, ""); + }); + + it("concatenates unrecognized values into the ghost input by default", () => { + const { filmQueryList, handlePaste } = mountForPasteTest(); + + const item2 = TOP_100_FILMS[6]; + const item4 = TOP_100_FILMS[3]; + + const pastedValue1 = "unrecognized1"; + const pastedValue2 = item2.title; + const pastedValue3 = "unrecognized2"; + const pastedValue4 = item4.title; + + handlePaste([pastedValue1, pastedValue2, pastedValue3, pastedValue4]); + + assert.isTrue(onItemsPaste.calledOnce); + // Emits just the 2 valid items. + assert.deepEqual(onItemsPaste.args[0][0], [item2, item4]); + // Highlight the last item pasted. + assert.deepEqual(filmQueryList.state().activeItem, item4); + assert.deepEqual(filmQueryList.state().query, "unrecognized1, unrecognized2"); + }); + + it("creates new items out of unrecognized values if 'Create item' option is enabled", () => { + const createdRank = 0; + const createdYear = 2019; + + const { filmQueryList, handlePaste } = mountForPasteTest({ + // Must pass these two props to enable the "Create item" option. + createNewItemFromQuery: query => ({ title: query, rank: createdRank, year: createdYear }), + createNewItemRenderer: () =>
Create item
, + }); + + const item1 = TOP_100_FILMS[6]; + const item2 = TOP_100_FILMS[3]; + + const pastedValue1 = item1.title; + const pastedValue2 = item2.title; + // Paste this item last. + const pastedValue3 = "unrecognized"; + + handlePaste([pastedValue1, pastedValue2, pastedValue3]); + const createdItem = { title: "unrecognized", rank: createdRank, year: createdYear }; + + assert.isTrue(onItemsPaste.calledOnce); + // Emits 2 existing items and 1 newly created item. + assert.deepEqual(onItemsPaste.args[0][0], [item1, item2, createdItem]); + // Highlight the last *already existing* item pasted. + assert.deepEqual(filmQueryList.state().activeItem, item2); + assert.deepEqual(filmQueryList.state().query, ""); + }); + }); });