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

Create-able Select components #3304

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/docs-app/src/examples/select-examples/films.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,11 @@ export const filmSelectProps = {
itemRenderer: renderFilm,
items: TOP_100_FILMS,
};

export function createFilm(title: string): IFilm {
return {
rank: 100 + Math.floor(Math.random() * 100 + 1),
Copy link
Contributor

Choose a reason for hiding this comment

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

😂

title,
year: new Date().getFullYear(),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as React from "react";
import { Button, H5, Intent, ITagProps, MenuItem, Switch } from "@blueprintjs/core";
import { Example, IExampleProps } from "@blueprintjs/docs-theme";
import { ItemRenderer, MultiSelect } from "@blueprintjs/select";
import { filmSelectProps, IFilm, TOP_100_FILMS } from "./films";
import { createFilm, filmSelectProps, IFilm, TOP_100_FILMS } from "./films";

const FilmMultiSelect = MultiSelect.ofType<IFilm>();

Expand All @@ -19,6 +19,7 @@ export interface IMultiSelectExampleState {
films: IFilm[];
hasInitialContent: boolean;
intent: boolean;
allowCreate: boolean;
openOnKeyDown: boolean;
popoverMinimal: boolean;
resetOnSelect: boolean;
Expand All @@ -27,6 +28,7 @@ export interface IMultiSelectExampleState {

export class MultiSelectExample extends React.PureComponent<IExampleProps, IMultiSelectExampleState> {
public state: IMultiSelectExampleState = {
allowCreate: false,
films: [],
hasInitialContent: false,
intent: false,
Expand All @@ -42,6 +44,7 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
private handleTagMinimalChange = this.handleSwitchChange("tagMinimal");
private handleIntentChange = this.handleSwitchChange("intent");
private handleInitialContentChange = this.handleSwitchChange("hasInitialContent");
private handleAllowCreateChange = this.handleSwitchChange("allowCreate");

public render() {
const { films, hasInitialContent, tagMinimal, popoverMinimal, ...flags } = this.state;
Expand All @@ -56,6 +59,8 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
// explicit undefined (not null) for default behavior (show full list)
undefined
);
const maybeCreateItemFromQuery = this.state.allowCreate ? createFilm : undefined;
const maybeCreateItemRenderer = this.state.allowCreate ? this.renderCreateFilmOption : null;

const clearButton = films.length > 0 ? <Button icon="cross" minimal={true} onClick={this.handleClear} /> : null;

Expand All @@ -72,6 +77,8 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
tagRenderer={this.renderTag}
tagInputProps={{ tagProps: getTagProps, onRemove: this.handleTagRemove, rightElement: clearButton }}
selectedItems={this.state.films}
createItemFromQuery={maybeCreateItemFromQuery}
createItemRenderer={maybeCreateItemRenderer}
/>
</Example>
);
Expand All @@ -96,6 +103,11 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
checked={this.state.hasInitialContent}
onChange={this.handleInitialContentChange}
/>
<Switch
label="Allow creating new films"
checked={this.state.allowCreate}
onChange={this.handleAllowCreateChange}
/>
<H5>Tag props</H5>
<Switch
label="Minimal tag style"
Expand Down Expand Up @@ -137,6 +149,10 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
);
};

private renderCreateFilmOption = (query: string, handleClick: React.MouseEventHandler<HTMLElement>) => (
<MenuItem icon="add" text={`Create "${query}"`} onClick={handleClick} shouldDismissPopover={false} />
);

private handleTagRemove = (_tag: string, index: number) => {
this.deselectFilm(index);
};
Expand Down
18 changes: 17 additions & 1 deletion packages/docs-app/src/examples/select-examples/selectExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import * as React from "react";
import { Button, H5, MenuItem, Switch } from "@blueprintjs/core";
import { Example, IExampleProps } from "@blueprintjs/docs-theme";
import { Select } from "@blueprintjs/select";
import { filmSelectProps, IFilm, TOP_100_FILMS } from "./films";
import { createFilm, filmSelectProps, IFilm, TOP_100_FILMS } from "./films";

const FilmSelect = Select.ofType<IFilm>();

export interface ISelectExampleState {
allowCreate: boolean;
film: IFilm;
filterable: boolean;
hasInitialContent: boolean;
Expand All @@ -27,6 +28,7 @@ export interface ISelectExampleState {

export class SelectExample extends React.PureComponent<IExampleProps, ISelectExampleState> {
public state: ISelectExampleState = {
allowCreate: false,
disableItems: false,
disabled: false,
film: TOP_100_FILMS[0],
Expand All @@ -38,6 +40,7 @@ export class SelectExample extends React.PureComponent<IExampleProps, ISelectExa
resetOnSelect: false,
};

private handleAllowCreateChange = this.handleSwitchChange("allowCreate");
private handleDisabledChange = this.handleSwitchChange("disabled");
private handleFilterableChange = this.handleSwitchChange("filterable");
private handleInitialContentChange = this.handleSwitchChange("hasInitialContent");
Expand All @@ -55,12 +58,16 @@ export class SelectExample extends React.PureComponent<IExampleProps, ISelectExa
) : (
undefined
);
const maybeCreateItemFromQuery = this.state.allowCreate ? createFilm : undefined;
const maybeCreateItemRenderer = this.state.allowCreate ? this.renderCreateFilmOption : null;

return (
<Example options={this.renderOptions()} {...this.props}>
<FilmSelect
{...filmSelectProps}
{...flags}
createItemFromQuery={maybeCreateItemFromQuery}
createItemRenderer={maybeCreateItemRenderer}
disabled={disabled}
itemDisabled={this.isItemDisabled}
initialContent={initialContent}
Expand Down Expand Up @@ -110,6 +117,11 @@ export class SelectExample extends React.PureComponent<IExampleProps, ISelectExa
checked={this.state.disableItems}
onChange={this.handleItemDisabledChange}
/>
<Switch
label="Allow creating new items"
checked={this.state.allowCreate}
onChange={this.handleAllowCreateChange}
/>
<H5>Popover props</H5>
<Switch
label="Minimal popover style"
Expand All @@ -120,6 +132,10 @@ export class SelectExample extends React.PureComponent<IExampleProps, ISelectExa
);
}

private renderCreateFilmOption = (query: string, handleClick: React.MouseEventHandler<HTMLElement>) => (
<MenuItem icon="add" text={`Create "${query}"`} onClick={handleClick} shouldDismissPopover={false} />
);

private handleValueChange = (film: IFilm) => this.setState({ film });

private handleSwitchChange(prop: keyof ISelectExampleState) {
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 @@ -98,6 +98,18 @@ export interface IListItemsProps<T> extends IProps {
*/
onQueryChange?: (query: string, event?: React.ChangeEvent<HTMLInputElement>) => void;

/**
* If provided, allows new items to be created with the current query string.
* This is invoked when user interaction causes a new item to be created, either by pressing the `enter` key or
* by clicking on the "Create Item" option. It transforms a query string into an item type.
*/
createItemFromQuery?: (query: string) => T;

/**
* Custom renderer to transform the current query string into a selectable "Create Item" option.
*/
createItemRenderer?: (query: string, handleClick: React.MouseEventHandler<HTMLElement>) => JSX.Element | undefined;

/**
* Whether the active item should be reset to the first matching item _every
* time the query changes_ (via prop or by user input).
Expand Down
39 changes: 34 additions & 5 deletions packages/select/src/components/query-list/queryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,19 @@ export class QueryList<T> extends React.Component<IQueryListProps<T>, IQueryList

/** default `itemListRenderer` implementation */
private renderItemList = (listProps: IItemListRendererProps<T>) => {
const { initialContent, noResults } = this.props;
const menuContent = renderFilteredItems(listProps, noResults, initialContent);
return <Menu ulRef={listProps.itemsParentRef}>{menuContent}</Menu>;
const { initialContent, noResults, createItemFromQuery, createItemRenderer } = this.props;

// omit noResults if createItemFromQuery and createItemRenderer are both supplied, and query is not empty
const maybeNoResults =
createItemFromQuery && createItemRenderer && this.state.query !== "" ? undefined : noResults;
const menuContent = renderFilteredItems(listProps, maybeNoResults, initialContent);
const createItemView = this.state.query !== "" ? this.renderCreateItemMenuItem(this.state.query) : null;
return (
<Menu ulRef={listProps.itemsParentRef}>
{menuContent}
{createItemView}
</Menu>
);
};

/** wrapper around `itemRenderer` to inject props */
Expand All @@ -238,6 +248,13 @@ export class QueryList<T> extends React.Component<IQueryListProps<T>, IQueryList
});
};

private renderCreateItemMenuItem = (query: string) => {
const handleClick: React.MouseEventHandler<HTMLElement> = evt => {
this.handleItemCreate(query, evt);
};
return Utils.safeInvoke(this.props.createItemRenderer, query, handleClick);
};

private getActiveElement() {
if (this.itemsParentRef != null) {
return this.itemsParentRef.children.item(this.getActiveIndex()) as HTMLElement;
Expand All @@ -260,6 +277,14 @@ export class QueryList<T> extends React.Component<IQueryListProps<T>, IQueryList
};
}

private handleItemCreate = (query: string, evt?: React.SyntheticEvent<HTMLElement>) => {
const item = Utils.safeInvoke(this.props.createItemFromQuery, query);
if (item != null) {
Utils.safeInvoke(this.props.onItemSelect, item, evt);
this.setQuery("", true);
}
};

private handleItemSelect = (item: T, event?: React.SyntheticEvent<HTMLElement>) => {
this.setActiveItem(item);
Utils.safeInvoke(this.props.onItemSelect, item, event);
Expand All @@ -286,9 +311,13 @@ export class QueryList<T> extends React.Component<IQueryListProps<T>, IQueryList
// using keyup for enter to play nice with Button's keyboard clicking.
// if we were to process enter on keydown, then Button would click itself on keyup
// and the popvoer would re-open out of our control :(.
if (event.keyCode === Keys.ENTER && activeItem != null) {
if (event.keyCode === Keys.ENTER) {
event.preventDefault();
this.handleItemSelect(activeItem, event);
if (activeItem == null) {
this.handleItemCreate(this.state.query, event);
} else {
this.handleItemSelect(activeItem, event);
}
}
Utils.safeInvoke(onKeyUp, event);
};
Expand Down
2 changes: 1 addition & 1 deletion packages/select/src/components/select/multi-select.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Use `MultiSelect<T>` for choosing multiple items in a list. The component render

<div class="@ns-callout @ns-intent-primary @ns-icon-info-sign">
<h4 class="@ns-heading">Generic components and custom filtering</h4>
For more information on controlled usage, generic components and custom filtering, visit the documentation for [`Select<T>`](#select/select-component).
For more information on controlled usage, generic components, creating new items, and custom filtering, visit the documentation for [`Select<T>`](#select/select-component).
</div>

@reactExample MultiSelectExample
Expand Down
6 changes: 6 additions & 0 deletions packages/select/src/components/select/select-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ This controlled usage allows you to implement all sorts of advanced behavior on
top of the basic `Select` interactions, such as windowed filtering for large
data sets.

@## Creating new items

Use the `createItemFromQuery` prop to allow `Select<T>` to create new items. `createItemFromQuery` specifies how to turn a
user-entered query string into an item of type `<T>` that `Select` understands. You should also provide the `createItemRenderer`
prop to render a custom `MenuItem` to indicate that users can create new items.
Copy link
Contributor

Choose a reason for hiding this comment

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

let's add a simple code sample here that demonstrates using both props.


@## JavaScript API

@interface ISelectProps
Expand Down
30 changes: 30 additions & 0 deletions packages/select/test/selectComponentSuite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,34 @@ export function selectComponentSuite<P extends IListItemsProps<IFilm>, S>(
assert.equal(testProps.onItemSelect.lastCall.args[0], activeItem);
});
});

describe("create", () => {
const testCreateProps = {
...testProps,
createItemFromQuery: sinon.spy(),
createItemRenderer: () => <textarea />,
};

it("renders create item if filtering returns empty list", () => {
const wrapper = render({
...testCreateProps,
items: [],
Copy link
Contributor

Choose a reason for hiding this comment

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

use the real item list so it's actually filtering instead of always being an empty list

noResults: <address />,
query: "non-existent film name",
});
assert.lengthOf(wrapper.find("address"), 0, "should not find noResults");
assert.lengthOf(wrapper.find(`textarea`), 1, "should find createItem");
});

it("enter invokes createItemFromQuery", () => {
const wrapper = render({
...testCreateProps,
items: [],
noResults: <address />,
query: "non-existent film name",
});
findInput(wrapper).simulate("keyup", { keyCode: Keys.ENTER });
assert.equal(testCreateProps.createItemFromQuery.args[0][0], "non-existent film name");
});
});
}