2022-10-03 18:01:34 +00:00
import Component from '@glimmer/component' ;
2018-12-10 16:44:37 +00:00
import { inject as service } from '@ember/service' ;
import { task } from 'ember-concurrency' ;
2022-10-03 18:01:34 +00:00
import { action } from '@ember/object' ;
import { tracked } from '@glimmer/tracking' ;
2022-01-06 23:34:26 +00:00
import { resolve } from 'rsvp' ;
import { filterOptions , defaultMatcher } from 'ember-power-select/utils/group-utils' ;
2019-06-03 20:25:59 +00:00
/ * *
* @ module SearchSelect
2022-01-06 23:34:26 +00:00
* The ` SearchSelect ` is an implementation of the [ ember - power - select ] ( https : //github.com/cibernox/ember-power-select) used for form elements where options come dynamically from the API.
2019-06-03 20:25:59 +00:00
* @ example
2022-10-03 18:01:34 +00:00
* < SearchSelect
* @ id = "policy"
* @ models = { { array "policies/acl" } }
* @ onChange = { { this . onChange } }
* @ inputValue = { { get @ model this . valuePath } }
* @ wildcardLabel = "role"
* @ fallbackComponent = "string-list"
* @ selectLimit = { { 1 } }
* @ backend = { { @ model . backend } }
* @ disallowNewItems = { { true } }
* class = { { if this . validationError "dropdown-has-error-border" } }
* / >
*
// * component functionality
2022-06-24 17:57:19 +00:00
* @ param { function } onChange - The onchange action for this form field . * * SEE UTIL * * search - select - has - many . js if selecting models from a hasMany relationship
2022-10-03 18:01:34 +00:00
* @ param { array } [ inputValue ] - Array of strings corresponding to the input ' s initial value , e . g . an array of model ids that on edit will appear as selected items below the input
2022-01-06 23:34:26 +00:00
* @ param { boolean } [ disallowNewItems = false ] - Controls whether or not the user can add a new item if none found
2022-10-03 18:01:34 +00:00
* @ param { boolean } [ shouldRenderName = false ] - By default an item 's id renders in the dropdown, `true` displays the name with its id in smaller text beside it *NOTE: the boolean flips automatically with ' identity ' models or if this.idKey !== ' id '
* @ param { array } [ parentManageSelected ] - Array of selected items if the parent is keeping track of selections , see mfa - login - enforcement - form . js
* @ param { boolean } [ passObject = false ] - When true , the onChange callback returns an array of objects with id ( string ) and isNew ( boolean ) ( and any params from objectKeys ) . By default - onChange returns an array of id strings .
* @ param { array } [ objectKeys ] - Array of values that correlate to model attrs . Used to render attr other than 'id' beside the name if shouldRenderName = true . If passObject = true , objectKeys are added to the passed , selected object .
* @ param { number } [ selectLimit ] - Sets select limit
// * query params for dropdown items
* @ param { Array } models - An array of model types to fetch from the API .
* @ param { string } [ backend ] - name of the backend if the query for options needs additional information ( eg . secret backend )
* @ param { object } [ queryObject ] - object passed as query options to this . store . query ( ) . NOTE : will override @ backend
// * template only/display args
* @ param { string } id - The name of the form field
* @ param { string } [ label ] - Label for this form field
* @ param { string } [ labelClass ] - overwrite default label size ( 14 px ) from class = "is-label"
2022-01-06 23:34:26 +00:00
* @ param { string } [ subText ] - Text to be displayed below the label
2022-10-03 18:01:34 +00:00
* @ param { string } fallbackComponent - name of component to be rendered if the API call 403 s
* @ param { string } [ helpText ] - Text to be displayed in the info tooltip for this form field
* @ param { string } [ wildcardLabel ] - string ( singular ) for rendering label tag beside a wildcard selection ( i . e . 'role*' ) , for the number of items it includes , e . g . @ wildcardLabel = "role" - > "includes 4 roles"
2022-02-04 18:44:13 +00:00
* @ param { string } [ placeholder ] - text you wish to replace the default "search" with
2022-10-03 18:01:34 +00:00
* @ param { boolean } [ displayInherit = false ] - if you need the search select component to display inherit instead of box .
* @ param { function } [ renderInfoTooltip ] - receives each inputValue string and list of dropdownOptions as args , so parent can determine when to render a tooltip beside a selectedOption and the tooltip text . see 'oidc/provider-form.js'
2019-06-03 20:25:59 +00:00
*
2022-10-03 18:01:34 +00:00
// * advanced customization
* @ param { Array } options - array of objects passed directly to the power - select component . If doing this , ` models ` should not also be passed as that will overwrite the
* passed options . ex : [ { name : 'namespace45' , id : 'displayedName' } ] . It ' s recommended the parent should manage the array of selected items if manually passing in options .
* @ param { function } search - Customizes how the power - select component searches for matches - see the power - select docs for more information .
2019-10-25 18:16:45 +00:00
*
2019-06-03 20:25:59 +00:00
* /
2022-10-03 18:01:34 +00:00
export default class SearchSelect extends Component {
@ service store ;
@ tracked shouldUseFallback = false ;
@ tracked selectedOptions = [ ] ; // array of selected options (initially set by @inputValue)
@ tracked dropdownOptions = [ ] ; // options that will render in dropdown, updates as selections are added/discarded
@ tracked allOptions = [ ] ; // both selected and unselected options, used for wildcard filter
get hidePowerSelect ( ) {
return this . selectedOptions . length >= this . args . selectLimit ;
}
get idKey ( ) {
// if objectKeys exists, use the first element of the array as the identifier
// make 'id' as the first element in objectKeys if you do not want to override the default of 'id'
return this . args . objectKeys ? this . args . objectKeys [ 0 ] : 'id' ;
}
get shouldRenderName ( ) {
return this . args . models ? . some ( ( model ) => model . includes ( 'identity' ) ) ||
this . idKey !== 'id' ||
this . args . shouldRenderName
? true
: false ;
}
addSearchText ( optionsToFormat ) {
// maps over array of objects or response from query
return optionsToFormat . toArray ( ) . map ( ( option ) => {
const id = option [ this . idKey ] ? option [ this . idKey ] : option . id ;
option . searchText = ` ${ option . name } ${ id } ` ;
2019-06-03 20:25:59 +00:00
return option ;
} ) ;
2022-10-03 18:01:34 +00:00
}
formatInputAndUpdateDropdown ( inputValues ) {
// inputValues are initially an array of strings from @inputValue
// map over so selectedOptions are objects
return inputValues . map ( ( option ) => {
2022-11-09 23:15:31 +00:00
const matchingOption = this . dropdownOptions . findBy ( this . idKey , option ) ;
2022-10-03 18:01:34 +00:00
// tooltip text comes from return of parent function
2022-11-09 23:15:31 +00:00
const addTooltip = this . args . renderInfoTooltip
2022-10-03 18:01:34 +00:00
? this . args . renderInfoTooltip ( option , this . dropdownOptions )
: false ;
// remove any matches from dropdown list
this . dropdownOptions . removeObject ( matchingOption ) ;
2019-06-04 17:44:58 +00:00
return {
id : option ,
name : matchingOption ? matchingOption . name : option ,
searchText : matchingOption ? matchingOption . searchText : option ,
2022-09-09 01:06:05 +00:00
addTooltip ,
2022-10-03 18:01:34 +00:00
// add additional attrs if we're using a dynamic idKey
2022-09-09 01:06:05 +00:00
... ( this . idKey !== 'id' && this . customizeObject ( matchingOption ) ) ,
2019-06-04 17:44:58 +00:00
} ;
2019-06-03 20:25:59 +00:00
} ) ;
2022-10-03 18:01:34 +00:00
}
@ task
* fetchOptions ( ) {
this . dropdownOptions = [ ] ; // reset dropdown anytime we re-fetch
if ( this . args . parentManageSelected ) {
// works in tandem with parent passing in @options directly
this . selectedOptions = this . args . parentManageSelected ;
2019-06-03 20:25:59 +00:00
}
2022-10-03 18:01:34 +00:00
if ( ! this . args . models ) {
if ( this . args . options ) {
const { options } = this . args ;
// if options are nested, let parent handle formatting - see path-filter-config-list.js
this . dropdownOptions = options . some ( ( e ) => Object . keys ( e ) . includes ( 'groupName' ) )
? options
: [ ... this . addSearchText ( options ) ] ;
if ( ! this . args . parentManageSelected ) {
// set selectedOptions and remove matches from dropdown list
this . selectedOptions = this . args . inputValue
? this . formatInputAndUpdateDropdown ( this . args . inputValue )
: [ ] ;
}
2019-10-25 18:16:45 +00:00
}
return ;
}
2022-10-03 18:01:34 +00:00
2022-11-09 23:15:31 +00:00
for ( const modelType of this . args . models ) {
2018-12-10 16:44:37 +00:00
try {
2022-10-03 18:01:34 +00:00
let queryParams = { } ;
if ( this . args . backend ) {
queryParams = { backend : this . args . backend } ;
2020-08-26 16:31:18 +00:00
}
2022-10-03 18:01:34 +00:00
if ( this . args . queryObject ) {
queryParams = this . args . queryObject ;
2022-09-09 01:06:05 +00:00
}
2022-10-03 18:01:34 +00:00
// fetch options from the store
2022-11-09 23:15:31 +00:00
const options = yield this . store . query ( modelType , queryParams ) ;
2022-10-03 18:01:34 +00:00
// store both select + unselected options in tracked property used by wildcard filter
this . allOptions = [ ... this . allOptions , ... options . mapBy ( 'id' ) ] ;
// add to dropdown options
this . dropdownOptions = [ ... this . dropdownOptions , ... this . addSearchText ( options ) ] ;
2018-12-10 16:44:37 +00:00
} catch ( err ) {
if ( err . httpStatus === 404 ) {
2022-10-03 18:01:34 +00:00
// continue to query other models even if one 404s
// and so selectedOptions will be set after for loop
continue ;
2018-12-10 16:44:37 +00:00
}
if ( err . httpStatus === 403 ) {
2022-10-03 18:01:34 +00:00
this . shouldUseFallback = true ;
2018-12-10 16:44:37 +00:00
return ;
}
throw err ;
}
}
2022-10-03 18:01:34 +00:00
// after all models are queried, set selectedOptions and remove matches from dropdown list
this . selectedOptions = this . args . inputValue
? this . formatInputAndUpdateDropdown ( this . args . inputValue )
: [ ] ;
}
@ action
2018-12-10 16:44:37 +00:00
handleChange ( ) {
if ( this . selectedOptions . length && typeof this . selectedOptions . firstObject === 'object' ) {
2022-10-03 18:01:34 +00:00
this . args . onChange (
Array . from ( this . selectedOptions , ( option ) =>
this . args . passObject ? this . customizeObject ( option ) : option . id
)
) ;
2018-12-10 16:44:37 +00:00
} else {
2022-10-03 18:01:34 +00:00
this . args . onChange ( this . selectedOptions ) ;
2018-12-10 16:44:37 +00:00
}
2022-10-03 18:01:34 +00:00
}
shouldShowCreate ( id , searchResults ) {
if ( searchResults && searchResults . length && searchResults . firstObject . groupName ) {
return ! searchResults . some ( ( group ) => group . options . findBy ( 'id' , id ) ) ;
2022-01-06 23:34:26 +00:00
}
2022-11-09 23:15:31 +00:00
const existingOption =
2022-10-03 18:01:34 +00:00
this . dropdownOptions &&
( this . dropdownOptions . findBy ( 'id' , id ) || this . dropdownOptions . findBy ( 'name' , id ) ) ;
if ( this . args . disallowNewItems && ! existingOption ) {
2022-01-06 23:34:26 +00:00
return false ;
}
return ! existingOption ;
2022-10-03 18:01:34 +00:00
}
// ----- adapted from ember-power-select-with-create
2022-01-06 23:34:26 +00:00
addCreateOption ( term , results ) {
if ( this . shouldShowCreate ( term , results ) ) {
2022-10-03 18:01:34 +00:00
const name = ` Click to add new item: ${ term } ` ;
2022-01-06 23:34:26 +00:00
const suggestion = {
_ _isSuggestion _ _ : true ,
_ _value _ _ : term ,
name ,
id : name ,
} ;
results . unshift ( suggestion ) ;
}
2022-10-03 18:01:34 +00:00
}
2022-01-06 23:34:26 +00:00
filter ( options , searchText ) {
const matcher = ( option , text ) => defaultMatcher ( option . searchText , text ) ;
return filterOptions ( options || [ ] , searchText , matcher ) ;
2022-10-03 18:01:34 +00:00
}
2022-01-06 23:34:26 +00:00
// -----
2022-10-03 18:01:34 +00:00
2022-09-09 01:06:05 +00:00
customizeObject ( option ) {
if ( ! option ) return ;
2022-10-03 18:01:34 +00:00
let additionalKeys ;
if ( this . args . objectKeys ) {
// pull attrs corresponding to objectKeys from model record, add to the selection
additionalKeys = Object . fromEntries ( this . args . objectKeys . map ( ( key ) => [ key , option [ key ] ] ) ) ;
// filter any undefined attrs, which could mean the model was not hydrated,
// the record is new or the model doesn't have that attribute
Object . keys ( additionalKeys ) . forEach ( ( key ) => {
if ( additionalKeys [ key ] === undefined ) {
delete additionalKeys [ key ] ;
}
} ) ;
2022-09-09 01:06:05 +00:00
}
2022-10-03 18:01:34 +00:00
return {
id : option . id ,
isNew : ! ! option . new ,
... additionalKeys ,
} ;
}
@ action
discardSelection ( selected ) {
this . selectedOptions . removeObject ( selected ) ;
if ( ! selected . new ) {
this . dropdownOptions . pushObject ( selected ) ;
}
this . handleChange ( ) ;
}
// ----- adapted from ember-power-select-with-create
@ action
searchAndSuggest ( term , select ) {
if ( term . length === 0 ) {
return this . dropdownOptions ;
}
if ( this . args . search ) {
return resolve ( this . args . search ( term , select ) ) . then ( ( results ) => {
if ( results . toArray ) {
results = results . toArray ( ) ;
}
this . addCreateOption ( term , results ) ;
return results ;
} ) ;
}
const newOptions = this . filter ( this . dropdownOptions , term ) ;
this . addCreateOption ( term , newOptions ) ;
return newOptions ;
}
@ action
selectOrCreate ( selection ) {
if ( selection && selection . _ _isSuggestion _ _ ) {
const name = selection . _ _value _ _ ;
this . selectedOptions . pushObject ( { name , id : name , new : true } ) ;
} else {
this . selectedOptions . pushObject ( selection ) ;
this . dropdownOptions . removeObject ( selection ) ;
}
this . handleChange ( ) ;
}
// -----
}