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

/**
* A type reflecting the manner in which a `TagInput` value was added.
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: instead of "reflecting the manner ...", simpler language would be "The method in which a TagInput value was added."

* - `"default"` - indicates that a value was added in the default fasion, via
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: "fashion"

actually I would just simplify, remove the metaphor, and say "... was added by manual selection"

* 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 +85,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 +271,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 +353,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 +378,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 +416,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
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 @@ -13,6 +13,7 @@ import {
areFilmsEqual,
arrayContainsFilm,
createFilm,
doesFilmEqualQuery,
filmSelectProps,
IFilm,
maybeAddCreatedFilmToArrays,
Expand Down Expand Up @@ -93,6 +94,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 +182,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 +235,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
20 changes: 15 additions & 5 deletions packages/select/src/common/predicate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,20 @@
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.
* Customize querying of individual items.
*
* If defined with `itemListPredicate`, this prop will be ignored.
* __Filtering a list of items.__ This function is invoked to filter the list of
Copy link
Contributor

Choose a reason for hiding this comment

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

it's weird to have the exact same documentation block in two places. it's especially weird to reference the prop names here, when we're not in a react component class module... can you simplify this to a simple one-sentence description and tell people to "see its usage sites in IListItemsProps ..." ?

* 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.
*/
export type ItemPredicate<T> = (query: string, item: T, index?: number) => boolean;
export type ItemPredicate<T> = (query: string, item: T, index?: number, exactMatch?: boolean) => boolean;
75 changes: 72 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
* `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,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