From 6668d078e3f673ab36554e074b0fe5c4be186773 Mon Sep 17 00:00:00 2001 From: Shuyang Li Date: Mon, 21 Jan 2019 12:40:20 -0500 Subject: [PATCH 01/30] Add option to create new item --- .../select-examples/multiSelectExample.tsx | 29 ++++++++++ .../src/components/query-list/queryList.tsx | 10 +++- .../src/components/select/multiSelect.tsx | 53 +++++++++++++++++-- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx b/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx index 54e45770a9..1c8f961786 100644 --- a/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx +++ b/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx @@ -23,6 +23,7 @@ export interface IMultiSelectExampleState { popoverMinimal: boolean; resetOnSelect: boolean; tagMinimal: boolean; + allowCreate: boolean; } export class MultiSelectExample extends React.PureComponent { @@ -34,6 +35,7 @@ export class MultiSelectExample extends React.PureComponent ) : ( @@ -72,6 +78,8 @@ export class MultiSelectExample extends React.PureComponent ); @@ -96,6 +104,11 @@ export class MultiSelectExample extends React.PureComponent +
Tag props
( + + ); + private handleTagRemove = (_tag: string, index: number) => { this.deselectFilm(index); }; @@ -173,4 +194,12 @@ export class MultiSelectExample extends React.PureComponent this.setState({ films: [] }); + + private handleCreateFilmFromQuery(query: string): IFilm { + return { + title: query, + year: new Date().getFullYear(), + rank: 100 + Math.floor(Math.random() * 100 + 1), + }; + } } diff --git a/packages/select/src/components/query-list/queryList.tsx b/packages/select/src/components/query-list/queryList.tsx index c76432f2f4..d274ae5940 100644 --- a/packages/select/src/components/query-list/queryList.tsx +++ b/packages/select/src/components/query-list/queryList.tsx @@ -29,6 +29,11 @@ export interface IQueryListProps extends IListItemsProps { * Receives an object with props that should be applied to elements as necessary. */ renderer: (listProps: IQueryListRendererProps) => JSX.Element; + + /** + * Any additional view rendered at the end of the query list. + */ + additionalElementRenderer?: (query: string) => JSX.Element; } /** @@ -216,9 +221,10 @@ export class QueryList extends React.Component, IQueryList /** default `itemListRenderer` implementation */ private renderItemList = (listProps: IItemListRendererProps) => { - const { initialContent, noResults } = this.props; + const { initialContent, noResults, additionalElementRenderer } = this.props; const menuContent = renderFilteredItems(listProps, noResults, initialContent); - return {menuContent}; + const additionalElement = Utils.safeInvoke(additionalElementRenderer, this.state.query); + return {menuContent}{additionalElement}; }; /** wrapper around `itemRenderer` to inject props */ diff --git a/packages/select/src/components/select/multiSelect.tsx b/packages/select/src/components/select/multiSelect.tsx index d9e3a0195a..a333aa0cbf 100644 --- a/packages/select/src/components/select/multiSelect.tsx +++ b/packages/select/src/components/select/multiSelect.tsx @@ -43,6 +43,20 @@ export interface IMultiSelectProps extends IListItemsProps { /** Custom renderer to transform an item into tag content. */ tagRenderer: (item: T) => React.ReactNode; + + /** + * If provided, allows new items to be created with the current query string in `MultiSelect`. + * 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. + * If `noResults` is also defined, this props takes priority and the other will be ignored. + * Ignored if `createItemFromQuery` is omitted. + */ + createItemRenderer?: (query: string) => JSX.Element; } export interface IMultiSelectState { @@ -78,21 +92,42 @@ export class MultiSelect extends React.PureComponent, IM public render() { // omit props specific to this component, spread the rest. - const { openOnKeyDown, popoverProps, tagInputProps, ...restProps } = this.props; - + // omit noResults if createItemFromQuery and createItemRenderer are both supplied + const { + createItemFromQuery, + createItemRenderer, + noResults, + openOnKeyDown, + popoverProps, + tagInputProps, + ...restProps + } = this.props; + const creatable = createItemFromQuery != null; + const maybeNoResults = (creatable && createItemRenderer) ? undefined : noResults; return ( ); } private renderQueryList = (listProps: IQueryListRendererProps) => { - const { tagInputProps = {}, popoverProps = {}, selectedItems = [], placeholder } = this.props; + const { + popoverProps = {}, + selectedItems = [], + placeholder, + createItemFromQuery, + } = this.props; + const tagInputProps: Partial & object = { + onAdd: createItemFromQuery != null ? this.handleItemCreate : undefined, + ...this.props.tagInputProps, + }; const { handleKeyDown, handleKeyUp } = listProps; return ( @@ -136,6 +171,18 @@ export class MultiSelect extends React.PureComponent, IM Utils.safeInvoke(this.props.onItemSelect, item, evt); }; + private handleItemCreate = (queries: string[]) => { + const { createItemFromQuery } = this.props; + if (createItemFromQuery == null) { + return; + } + + queries.forEach(query => { + const item = createItemFromQuery(query); + Utils.safeInvoke(this.props.onItemSelect, item, undefined); + }); + } + private handleQueryChange = (query: string, evt?: React.ChangeEvent) => { this.setState({ isOpen: query.length > 0 || !this.props.openOnKeyDown }); Utils.safeInvoke(this.props.onQueryChange, query, evt); From 3fcd3435ba6f99b6b8f6bb9380bd77183c803b13 Mon Sep 17 00:00:00 2001 From: Shuyang Li Date: Mon, 21 Jan 2019 13:10:09 -0500 Subject: [PATCH 02/30] Properly clear input on item click --- .../select-examples/multiSelectExample.tsx | 3 ++- .../src/components/query-list/queryList.tsx | 2 +- .../src/components/select/multiSelect.tsx | 18 ++++++++++++++---- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx b/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx index 1c8f961786..54e6ec75b6 100644 --- a/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx +++ b/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx @@ -150,10 +150,11 @@ export class MultiSelectExample extends React.PureComponent ( + private renderCreateFilmOption = (query: string, handleClick: React.MouseEventHandler) => ( ); diff --git a/packages/select/src/components/query-list/queryList.tsx b/packages/select/src/components/query-list/queryList.tsx index d274ae5940..ef2a9dec8c 100644 --- a/packages/select/src/components/query-list/queryList.tsx +++ b/packages/select/src/components/query-list/queryList.tsx @@ -33,7 +33,7 @@ export interface IQueryListProps extends IListItemsProps { /** * Any additional view rendered at the end of the query list. */ - additionalElementRenderer?: (query: string) => JSX.Element; + additionalElementRenderer?: (query: string) => JSX.Element | undefined; } /** diff --git a/packages/select/src/components/select/multiSelect.tsx b/packages/select/src/components/select/multiSelect.tsx index a333aa0cbf..579d597c5c 100644 --- a/packages/select/src/components/select/multiSelect.tsx +++ b/packages/select/src/components/select/multiSelect.tsx @@ -56,7 +56,7 @@ export interface IMultiSelectProps extends IListItemsProps { * If `noResults` is also defined, this props takes priority and the other will be ignored. * Ignored if `createItemFromQuery` is omitted. */ - createItemRenderer?: (query: string) => JSX.Element; + createItemRenderer?: (query: string, handleClick: React.MouseEventHandler) => JSX.Element; } export interface IMultiSelectState { @@ -112,7 +112,7 @@ export class MultiSelect extends React.PureComponent, IM onQueryChange={this.handleQueryChange} ref={this.refHandlers.queryList} renderer={this.renderQueryList} - additionalElementRenderer={creatable ? createItemRenderer : undefined} + additionalElementRenderer={creatable ? this.renderCreateItemMenuItem : undefined} /> ); } @@ -164,6 +164,13 @@ export class MultiSelect extends React.PureComponent, IM ); }; + private renderCreateItemMenuItem = (query: string) => { + const handleClick: React.MouseEventHandler = (evt) => { + this.handleItemCreate([query], evt); + } + return Utils.safeInvoke(this.props.createItemRenderer, query, handleClick); + } + private handleItemSelect = (item: T, evt?: React.SyntheticEvent) => { if (this.input != null) { this.input.focus(); @@ -171,7 +178,7 @@ export class MultiSelect extends React.PureComponent, IM Utils.safeInvoke(this.props.onItemSelect, item, evt); }; - private handleItemCreate = (queries: string[]) => { + private handleItemCreate = (queries: string[], evt?: React.SyntheticEvent) => { const { createItemFromQuery } = this.props; if (createItemFromQuery == null) { return; @@ -179,8 +186,9 @@ export class MultiSelect extends React.PureComponent, IM queries.forEach(query => { const item = createItemFromQuery(query); - Utils.safeInvoke(this.props.onItemSelect, item, undefined); + Utils.safeInvoke(this.props.onItemSelect, item, evt); }); + this.resetQuery(); } private handleQueryChange = (query: string, evt?: React.ChangeEvent) => { @@ -232,4 +240,6 @@ export class MultiSelect extends React.PureComponent, IM } }; }; + + private resetQuery = () => this.queryList && this.queryList.setQuery("", true); } From b42418f6b6a65e778f65e728c7ac6364abfa1abf Mon Sep 17 00:00:00 2001 From: Shuyang Li Date: Mon, 21 Jan 2019 14:17:35 -0500 Subject: [PATCH 03/30] Move props to queryList --- packages/select/src/common/listItemsProps.ts | 12 ++++ .../src/components/query-list/queryList.tsx | 39 ++++++++---- .../src/components/select/multiSelect.tsx | 62 +------------------ 3 files changed, 42 insertions(+), 71 deletions(-) diff --git a/packages/select/src/common/listItemsProps.ts b/packages/select/src/common/listItemsProps.ts index 43cf53b951..c81d9d604b 100644 --- a/packages/select/src/common/listItemsProps.ts +++ b/packages/select/src/common/listItemsProps.ts @@ -98,6 +98,18 @@ export interface IListItemsProps extends IProps { */ onQueryChange?: (query: string, event?: React.ChangeEvent) => 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) => 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). diff --git a/packages/select/src/components/query-list/queryList.tsx b/packages/select/src/components/query-list/queryList.tsx index ef2a9dec8c..c43702d304 100644 --- a/packages/select/src/components/query-list/queryList.tsx +++ b/packages/select/src/components/query-list/queryList.tsx @@ -29,11 +29,6 @@ export interface IQueryListProps extends IListItemsProps { * Receives an object with props that should be applied to elements as necessary. */ renderer: (listProps: IQueryListRendererProps) => JSX.Element; - - /** - * Any additional view rendered at the end of the query list. - */ - additionalElementRenderer?: (query: string) => JSX.Element | undefined; } /** @@ -221,10 +216,13 @@ export class QueryList extends React.Component, IQueryList /** default `itemListRenderer` implementation */ private renderItemList = (listProps: IItemListRendererProps) => { - const { initialContent, noResults, additionalElementRenderer } = this.props; - const menuContent = renderFilteredItems(listProps, noResults, initialContent); - const additionalElement = Utils.safeInvoke(additionalElementRenderer, this.state.query); - return {menuContent}{additionalElement}; + const { initialContent, noResults, createItemFromQuery, createItemRenderer } = this.props; + + // omit noResults if createItemFromQuery and createItemRenderer are both supplied + const maybeNoResults = (createItemFromQuery && createItemRenderer) ? undefined : noResults; + const menuContent = renderFilteredItems(listProps, maybeNoResults, initialContent); + const createItemView = this.renderCreateItemMenuItem(this.state.query); + return {menuContent}{createItemView}; }; /** wrapper around `itemRenderer` to inject props */ @@ -244,6 +242,13 @@ export class QueryList extends React.Component, IQueryList }); }; + private renderCreateItemMenuItem = (query: string) => { + const handleClick: React.MouseEventHandler = (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; @@ -266,6 +271,14 @@ export class QueryList extends React.Component, IQueryList }; } + private handleItemCreate = (query: string, evt?: React.SyntheticEvent) => { + 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) => { this.setActiveItem(item); Utils.safeInvoke(this.props.onItemSelect, item, event); @@ -292,9 +305,13 @@ export class QueryList extends React.Component, 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); }; diff --git a/packages/select/src/components/select/multiSelect.tsx b/packages/select/src/components/select/multiSelect.tsx index 579d597c5c..11e9271402 100644 --- a/packages/select/src/components/select/multiSelect.tsx +++ b/packages/select/src/components/select/multiSelect.tsx @@ -43,20 +43,6 @@ export interface IMultiSelectProps extends IListItemsProps { /** Custom renderer to transform an item into tag content. */ tagRenderer: (item: T) => React.ReactNode; - - /** - * If provided, allows new items to be created with the current query string in `MultiSelect`. - * 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. - * If `noResults` is also defined, this props takes priority and the other will be ignored. - * Ignored if `createItemFromQuery` is omitted. - */ - createItemRenderer?: (query: string, handleClick: React.MouseEventHandler) => JSX.Element; } export interface IMultiSelectState { @@ -92,42 +78,20 @@ export class MultiSelect extends React.PureComponent, IM public render() { // omit props specific to this component, spread the rest. - // omit noResults if createItemFromQuery and createItemRenderer are both supplied - const { - createItemFromQuery, - createItemRenderer, - noResults, - openOnKeyDown, - popoverProps, - tagInputProps, - ...restProps - } = this.props; - const creatable = createItemFromQuery != null; - const maybeNoResults = (creatable && createItemRenderer) ? undefined : noResults; + const { openOnKeyDown, popoverProps, tagInputProps, ...restProps } = this.props; return ( ); } private renderQueryList = (listProps: IQueryListRendererProps) => { - const { - popoverProps = {}, - selectedItems = [], - placeholder, - createItemFromQuery, - } = this.props; - const tagInputProps: Partial & object = { - onAdd: createItemFromQuery != null ? this.handleItemCreate : undefined, - ...this.props.tagInputProps, - }; + const { tagInputProps = {}, popoverProps = {}, selectedItems = [], placeholder } = this.props; const { handleKeyDown, handleKeyUp } = listProps; return ( @@ -164,13 +128,6 @@ export class MultiSelect extends React.PureComponent, IM ); }; - private renderCreateItemMenuItem = (query: string) => { - const handleClick: React.MouseEventHandler = (evt) => { - this.handleItemCreate([query], evt); - } - return Utils.safeInvoke(this.props.createItemRenderer, query, handleClick); - } - private handleItemSelect = (item: T, evt?: React.SyntheticEvent) => { if (this.input != null) { this.input.focus(); @@ -178,19 +135,6 @@ export class MultiSelect extends React.PureComponent, IM Utils.safeInvoke(this.props.onItemSelect, item, evt); }; - private handleItemCreate = (queries: string[], evt?: React.SyntheticEvent) => { - const { createItemFromQuery } = this.props; - if (createItemFromQuery == null) { - return; - } - - queries.forEach(query => { - const item = createItemFromQuery(query); - Utils.safeInvoke(this.props.onItemSelect, item, evt); - }); - this.resetQuery(); - } - private handleQueryChange = (query: string, evt?: React.ChangeEvent) => { this.setState({ isOpen: query.length > 0 || !this.props.openOnKeyDown }); Utils.safeInvoke(this.props.onQueryChange, query, evt); @@ -240,6 +184,4 @@ export class MultiSelect extends React.PureComponent, IM } }; }; - - private resetQuery = () => this.queryList && this.queryList.setQuery("", true); } From 2548a01f0636765497003a3942c1177ea961d1cf Mon Sep 17 00:00:00 2001 From: Shuyang Li Date: Mon, 21 Jan 2019 15:20:14 -0500 Subject: [PATCH 04/30] Lint and test --- .../src/examples/select-examples/films.tsx | 8 +++++ .../select-examples/multiSelectExample.tsx | 19 ++---------- .../src/components/query-list/queryList.tsx | 22 +++++++++----- packages/select/test/selectComponentSuite.tsx | 30 +++++++++++++++++++ 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/packages/docs-app/src/examples/select-examples/films.tsx b/packages/docs-app/src/examples/select-examples/films.tsx index a92fd3dafc..71f77c0055 100644 --- a/packages/docs-app/src/examples/select-examples/films.tsx +++ b/packages/docs-app/src/examples/select-examples/films.tsx @@ -182,3 +182,11 @@ export const filmSelectProps = { itemRenderer: renderFilm, items: TOP_100_FILMS, }; + +export function createFilm(title: string): IFilm { + return { + title, + year: new Date().getFullYear(), + rank: 100 + Math.floor(Math.random() * 100 + 1), + }; +} diff --git a/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx b/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx index 54e6ec75b6..266d5ec3d8 100644 --- a/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx +++ b/packages/docs-app/src/examples/select-examples/multiSelectExample.tsx @@ -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(); @@ -53,7 +53,7 @@ export class MultiSelectExample extends React.PureComponent) => ( - + ); private handleTagRemove = (_tag: string, index: number) => { @@ -195,12 +190,4 @@ export class MultiSelectExample extends React.PureComponent this.setState({ films: [] }); - - private handleCreateFilmFromQuery(query: string): IFilm { - return { - title: query, - year: new Date().getFullYear(), - rank: 100 + Math.floor(Math.random() * 100 + 1), - }; - } } diff --git a/packages/select/src/components/query-list/queryList.tsx b/packages/select/src/components/query-list/queryList.tsx index c43702d304..45e2b31e6b 100644 --- a/packages/select/src/components/query-list/queryList.tsx +++ b/packages/select/src/components/query-list/queryList.tsx @@ -218,11 +218,17 @@ export class QueryList extends React.Component, IQueryList private renderItemList = (listProps: IItemListRendererProps) => { const { initialContent, noResults, createItemFromQuery, createItemRenderer } = this.props; - // omit noResults if createItemFromQuery and createItemRenderer are both supplied - const maybeNoResults = (createItemFromQuery && createItemRenderer) ? undefined : noResults; + // 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.renderCreateItemMenuItem(this.state.query); - return {menuContent}{createItemView}; + const createItemView = this.state.query !== "" ? this.renderCreateItemMenuItem(this.state.query) : null; + return ( + + {menuContent} + {createItemView} + + ); }; /** wrapper around `itemRenderer` to inject props */ @@ -243,11 +249,11 @@ export class QueryList extends React.Component, IQueryList }; private renderCreateItemMenuItem = (query: string) => { - const handleClick: React.MouseEventHandler = (evt) => { + const handleClick: React.MouseEventHandler = evt => { this.handleItemCreate(query, evt); - } + }; return Utils.safeInvoke(this.props.createItemRenderer, query, handleClick); - } + }; private getActiveElement() { if (this.itemsParentRef != null) { @@ -277,7 +283,7 @@ export class QueryList extends React.Component, IQueryList Utils.safeInvoke(this.props.onItemSelect, item, evt); this.setQuery("", true); } - } + }; private handleItemSelect = (item: T, event?: React.SyntheticEvent) => { this.setActiveItem(item); diff --git a/packages/select/test/selectComponentSuite.tsx b/packages/select/test/selectComponentSuite.tsx index ec6fc82abc..0e453e6e26 100644 --- a/packages/select/test/selectComponentSuite.tsx +++ b/packages/select/test/selectComponentSuite.tsx @@ -119,4 +119,34 @@ export function selectComponentSuite

, S>( assert.equal(testProps.onItemSelect.lastCall.args[0], activeItem); }); }); + + describe("create", () => { + const testCreateProps = { + ...testProps, + createItemFromQuery: sinon.spy(), + createItemRenderer: () =>