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
32 changes: 26 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,26 @@ import * as Utils from "../../common/utils";
import { Icon, IconName } from "../icon/icon";
import { ITagProps, Tag } from "../tag/tag";

/**
* An enumeration of methods in which a `TagInput` value might have been added.
*/
export enum TagInputAddMethod {
Copy link
Contributor

Choose a reason for hiding this comment

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

I would prefer the const/type approach so users can use string literals instead of importing this long-named type in order to switch on it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@giladgray Product teams have requested string-literal enums in the past, but happy to do a simpler type. Is this what you mean?

type TagInputAddMethod = "default" | "blur" | "paste";

Or perhaps just inline it?

onAdd?: (values: string[], method: "default" | "blur" | "paste") => boolean | void;

Copy link
Contributor

Choose a reason for hiding this comment

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

I like the first one,

export type TagInputAddMethod = "default" | "blur" | "paste";

Copy link
Contributor Author

@cmslewis cmslewis Mar 19, 2019

Choose a reason for hiding this comment

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

/** Indicates that a value was added in the default fasion, via manual selection. */
DEFAULT = "default",

/**
* Indicates that a value was added when the `TagInput` lost focus. This is
* only possible when `addOnBlur=true`.
*/
BLUR = "blur",

/**
* Indicates that a value was added via paste. This is only possible when
* `addOnPaste=true`.
*/
PASTE = "paste",
}

export interface ITagInputProps extends IIntentProps, IProps {
/**
* If true, `onAdd` will be invoked when the input loses focus.
Expand Down Expand Up @@ -74,7 +94,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 +280,10 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
);
}

private addTags = (value: string) => {
private addTags = (value: string, method: TagInputAddMethod = 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 +362,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, TagInputAddMethod.BLUR);
}
this.setState({ activeIndex: NONE, isInputFocused: false });
}
Expand All @@ -367,7 +387,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, TagInputAddMethod.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 +425,7 @@ export class TagInput extends AbstractPureComponent<ITagInputProps, ITagInputSta
}

event.preventDefault();
this.addTags(value);
this.addTags(value, TagInputAddMethod.PASTE);
};

private handleRemoveTag = (event: React.MouseEvent<HTMLSpanElement>) => {
Expand Down
4 changes: 4 additions & 0 deletions packages/docs-app/src/examples/select-examples/films.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,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 @@ -13,6 +13,7 @@ import {
areFilmsEqual,
arrayContainsFilm,
createFilm,
doesFilmEqualQuery,
filmSelectProps,
IFilm,
maybeAddCreatedFilmToArrays,
Expand Down Expand Up @@ -88,11 +89,13 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
initialContent={initialContent}
itemRenderer={this.renderFilm}
itemsEqual={areFilmsEqual}
itemEqualsQuery={doesFilmEqualQuery}
// we may customize the default filmSelectProps.items by
// adding newly created items to the list, so pass our own
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 +183,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 +236,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
12 changes: 12 additions & 0 deletions packages/select/src/common/listItemsProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export interface IListItemsProps<T> extends IProps {
/** Array of items in the list. */
items: T[];

/**
* Whether the provided item fully matches the provided value. This is used
* to match pasted values to existing items, so that items can be emitted
* via `onItemsPaste`.
*/
itemEqualsQuery?: (item: T, query: string) => boolean;
Copy link
Contributor

Choose a reason for hiding this comment

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

isn't this just itemPredicate? https://github.com/palantir/blueprint/blob/develop/packages/select/src/common/predicate.ts#L23

i don't see the need for a new prop here, except that it slightly changes the semantics of itemPredicate in that you may want to define itemListPredicate for fast querying and itemPredicate for pasting, instead of one-or-the-other.

Copy link
Contributor Author

@cmslewis cmslewis Mar 19, 2019

Choose a reason for hiding this comment

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

@giladgray They're unfortunately different:

  • itemPredicate is for filtering: an item matches if the query matches any substring of the item title (e.g.)
  • itemEqualsQuery is for exact matching: an item matches iff the query === the full item title (e.g.)

Possible to add an exactMatch flag or something in ItemPredicate<T>?

EDIT: Some examples:

Example 1. If I paste "The Godfather," then itemPredicate will return true for The Godfather and The Godfather: Part II; whereas itemEqualsValue will return true for only The Godfather.

Example 2. If I paste "a" with the "Create item" option enabled, then itemPredicate will return true for every item containing an "a," while itemEqualsValue will return false for all items and cause a new item to be created.


/**
* Specifies how to test if two items are equal. By default, simple strict
* equality (`===`) is used to compare two items.
Expand Down Expand Up @@ -130,6 +137,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
74 changes: 71 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 `itemEqualsQuery` 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 `itemEqualsQuery` returns multiple matching items for a particular
* `value`, then only the first matching item will be emitted.
Copy link
Contributor

Choose a reason for hiding this comment

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

do you mean "for a particular query"? if not, what is value here?

*/
handlePaste: (values: 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,40 @@ export class QueryList<T> extends React.Component<IQueryListProps<T>, IQueryList
}
};

private handlePaste = (values: string[]) => {
const { createNewItemFromQuery, onItemsPaste } = this.props;

let nextActiveItem: T | undefined;

// 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 = values.reduce<T[]>((agg, value) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I would slightly prefer this as a regular for loop, since that would be easier to read than a reduce which produces a list

const equalItem = getMatchingItem(value, this.props);

if (equalItem !== undefined) {
nextActiveItem = equalItem;
agg.push(equalItem);
} else if (this.canCreateItems()) {
const newItem = Utils.safeInvoke(createNewItemFromQuery, value);
if (newItem !== undefined) {
agg.push(newItem);
}
}

return agg;
}, []);

// UX nicety: update the active item if we matched with at least one
// existing item.
if (nextActiveItem !== undefined) {
this.setActiveItem(nextActiveItem);
}

// No need to update the active item.
Copy link
Contributor

Choose a reason for hiding this comment

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

what's this comment about? you may have updated the active item at this point, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Old cruft. Removed. 👍

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 +471,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 +481,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 +498,19 @@ function pxToNumber(value: string | null) {
return value == null ? 0 : parseInt(value.slice(0, -2), 10);
}

function getMatchingItem<T>(query: string, { items, itemEqualsQuery }: IQueryListProps<T>): T | undefined {
if (Utils.isFunction(itemEqualsQuery)) {
// .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 (const item of items) {
if (itemEqualsQuery(item, query)) {
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
15 changes: 14 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 @@ -83,6 +84,7 @@ export class MultiSelect<T> extends React.PureComponent<IMultiSelectProps<T>, IM
<this.TypedQueryList
{...restProps}
onItemSelect={this.handleItemSelect}
onItemsPaste={this.handleItemsPaste}
onQueryChange={this.handleQueryChange}
ref={this.refHandlers.queryList}
renderer={this.renderQueryList}
Expand All @@ -92,7 +94,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 === TagInputAddMethod.PASTE) {
handlePaste(values);
}
};

return (
<Popover
Expand All @@ -117,6 +125,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 All @@ -135,6 +144,10 @@ export class MultiSelect<T> extends React.PureComponent<IMultiSelectProps<T>, IM
Utils.safeInvoke(this.props.onItemSelect, item, evt);
};

private handleItemsPaste = (items: T[]) => {
Utils.safeInvoke(this.props.onItemsPaste, items);
};

private handleQueryChange = (query: string, evt?: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ isOpen: query.length > 0 || !this.props.openOnKeyDown });
Utils.safeInvoke(this.props.onQueryChange, query, evt);
Expand Down