Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MultiSelect] Enable pasting of multiple values (same as TagInput) #3428

Merged
merged 12 commits into from
Mar 22, 2019
22 changes: 16 additions & 6 deletions packages/core/src/components/tag-input/tagInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -260,10 +270,10 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
);
}

private addTags = (value: string) => {
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;
Expand Down Expand Up @@ -342,7 +352,7 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
// defer this check using rAF so activeElement will have updated.
if (!currentTarget.contains(document.activeElement)) {
if (this.props.addOnBlur && this.state.inputValue !== undefined && this.state.inputValue.length > 0) {
this.addTags(this.state.inputValue);
this.addTags(this.state.inputValue, "blur");
}
this.setState({ activeIndex: NONE, isInputFocused: false });
}
Expand All @@ -367,7 +377,7 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
let activeIndexToEmit = activeIndex;

if (event.which === Keys.ENTER && value.length > 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.
Expand Down Expand Up @@ -405,7 +415,7 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
}

event.preventDefault();
this.addTags(value);
this.addTags(value, "paste");
};

private handleRemoveTag = (event: React.MouseEvent<HTMLSpanElement>) => {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/test/tag-input/tagInputTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ describe("<TagInput>", () => {
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 => {
Expand All @@ -140,6 +141,8 @@ describe("<TagInput>", () => {
// 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();
});
});
Expand Down Expand Up @@ -183,6 +186,7 @@ describe("<TagInput>", () => {
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", () => {
Expand Down
15 changes: 13 additions & 2 deletions packages/docs-app/src/examples/select-examples/films.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,15 @@ export const renderCreateFilmOption = (
/>
);

export const filterFilm: ItemPredicate<IFilm> = (query, film) => {
return `${film.rank}. ${film.title.toLowerCase()} ${film.year}`.indexOf(query.toLowerCase()) >= 0;
export const filterFilm: ItemPredicate<IFilm> = (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) {
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
items={this.state.items}
noResults={<MenuItem disabled={true} text="No results." />}
onItemSelect={this.handleFilmSelect}
onItemsPaste={this.handleFilmsPaste}
popoverProps={{ minimal: popoverMinimal }}
tagRenderer={this.renderTag}
tagInputProps={{ tagProps: getTagProps, onRemove: this.handleTagRemove, rightElement: clearButton }}
Expand Down Expand Up @@ -180,20 +181,29 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
}

private selectFilm(film: IFilm) {
const { films } = this.state;
this.selectFilms([film]);
}

const { createdItems: nextCreatedItems, items: nextItems } = maybeAddCreatedFilmToArrays(
this.state.items,
this.state.createdItems,
film,
);
private selectFilms(filmsToSelect: IFilm[]) {
const { createdItems, films, items } = this.state;

this.setState({
createdItems: nextCreatedItems,
let nextCreatedItems = createdItems.slice();
let nextFilms = films.slice();
let nextItems = items.slice();

filmsToSelect.forEach(film => {
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,
});
}
Expand Down Expand Up @@ -224,6 +234,12 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
}
};

private handleFilmsPaste = (films: IFilm[]) => {
// 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<HTMLInputElement>) => {
const checked = event.currentTarget.checked;
Expand Down
23 changes: 19 additions & 4 deletions packages/select/src/common/listItemsProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,21 @@ export interface IListItemsProps<T> extends IProps {
itemListPredicate?: ItemListPredicate<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.
* 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<T>;

Expand Down Expand Up @@ -130,6 +140,11 @@ export interface IListItemsProps<T> extends IProps {
*/
onItemSelect: (item: T, event?: React.SyntheticEvent<HTMLElement>) => void;

/**
* Callback invoked when multiple items are selected at once via pasting.
*/
onItemsPaste?: (items: T[]) => void;

/**
* Callback invoked when the query string changes.
*/
Expand Down
16 changes: 5 additions & 11 deletions packages/select/src/common/predicate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = (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<T> = (query: string, item: T, index?: number) => boolean;
export type ItemPredicate<T> = (query: string, item: T, index?: number, exactMatch?: boolean) => boolean;
82 changes: 79 additions & 3 deletions packages/select/src/components/query-list/queryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@ export interface IQueryListRendererProps<T> // Omit `createNewItem`, because it
*/
handleItemSelect: (item: T, event?: React.SyntheticEvent<HTMLElement>) => 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.
Expand Down Expand Up @@ -152,6 +170,7 @@ export class QueryList<T> extends React.Component<IQueryListProps<T>, IQueryList
handleItemSelect: this.handleItemSelect,
handleKeyDown: this.handleKeyDown,
handleKeyUp: this.handleKeyUp,
handlePaste: this.handlePaste,
handleQueryChange: this.handleQueryChange,
itemList: itemListRenderer({
...spreadableState,
Expand Down Expand Up @@ -350,6 +369,47 @@ export class QueryList<T> extends React.Component<IQueryListProps<T>, 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<HTMLElement>) => {
const { keyCode } = event;
if (keyCode === Keys.ARROW_UP || keyCode === Keys.ARROW_DOWN) {
Expand Down Expand Up @@ -418,10 +478,8 @@ export class QueryList<T> extends React.Component<IQueryListProps<T>, 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
Expand All @@ -430,6 +488,10 @@ export class QueryList<T> extends React.Component<IQueryListProps<T>, 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.
Expand All @@ -443,6 +505,20 @@ function pxToNumber(value: string | null) {
return value == null ? 0 : parseInt(value.slice(0, -2), 10);
}

function getMatchingItem<T>(query: string, { items, itemPredicate }: IQueryListProps<T>): T | undefined {
if (Utils.isFunction(itemPredicate)) {
// .find() doesn't exist in ES5. Alternative: use a for loop instead of
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK, Array.prototype.find should be supported in blueprint's environment.. can you elaborate here why that doesn't work?

Copy link
Contributor Author

@cmslewis cmslewis Mar 22, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adidahiya Yep, the compile step was failing on Circle. Here is the failing build from a few commits back.

lerna ERR! src/components/query-list/queryList.tsx:384:67 - error TS2339: Property 'find' does not exist on type 'T[]'.
lerna ERR! 
lerna ERR! 384                 itemEqualsQuery === undefined ? undefined : items.find(item => itemEqualsQuery(item, value));

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's unfortunate, I wish we had added it to the environment requirements. oh well.

// .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<T>(query: string, { items, itemPredicate, itemListPredicate }: IQueryListProps<T>) {
if (Utils.isFunction(itemListPredicate)) {
// note that implementations can reorder the items here
Expand Down
10 changes: 9 additions & 1 deletion packages/select/src/components/select/multiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Popover,
Position,
TagInput,
TagInputAddMethod,
Utils,
} from "@blueprintjs/core";
import { Classes, IListItemsProps } from "../../common";
Expand Down Expand Up @@ -92,7 +93,13 @@ export class MultiSelect<T> extends React.PureComponent<IMultiSelectProps<T>, IM

private renderQueryList = (listProps: IQueryListRendererProps<T>) => {
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 (
<Popover
Expand All @@ -117,6 +124,7 @@ export class MultiSelect<T> extends React.PureComponent<IMultiSelectProps<T>, 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)}
/>
Expand Down
Loading