2023-03-15 16:00:52 +00:00
/ * *
* Copyright ( c ) HashiCorp , Inc .
* SPDX - License - Identifier : MPL - 2.0
* /
2022-07-27 21:18:22 +00:00
import Component from '@glimmer/component' ;
import { inject as service } from '@ember/service' ;
import { action } from '@ember/object' ;
import { tracked } from '@glimmer/tracking' ;
2022-11-19 01:29:04 +00:00
import { task } from 'ember-concurrency' ;
2022-07-27 21:18:22 +00:00
import { filterOptions , defaultMatcher } from 'ember-power-select/utils/group-utils' ;
/ * *
* @ module SearchSelectWithModal
2022-11-19 01:29:04 +00:00
* The ` SearchSelectWithModal ` 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.
* It renders a passed template component that parents a form so records can be created inline , via a modal that pops up after clicking 'No results found for "${term}". Click here to create it.' from the dropdown menu .
* * * ! ! NOTE : any form passed must be able to receive an @ onSave and @ onCancel arg so that the modal will close properly . See ` oidc/client-form.hbs ` that renders a modal for the ` oidc-assignment-template.hbs ` as an example .
2022-07-27 21:18:22 +00:00
* @ example
* < SearchSelectWithModal
2022-11-19 01:29:04 +00:00
* @ id = "assignments"
* @ models = { { array "oidc/assignment" } }
* @ label = "assignment name"
* @ subText = "Search for an existing assignment, or type a new name to create it."
* @ inputValue = { { map - by "id" @ model . assignments } }
* @ onChange = { { this . handleSearchSelect } }
* { { ! since this is the "limited" radio select option we do not want to include 'allow_all' } }
* @ excludeOptions = { { array "allow_all" } }
* @ fallbackComponent = "string-list"
* @ modalFormTemplate = "modal-form/some-template"
* @ modalSubtext = "Use assignment to specify which Vault entities and groups are allowed to authenticate."
* / >
2022-07-27 21:18:22 +00:00
*
2022-11-19 01:29:04 +00:00
// * component functionality
* @ param { function } onChange - The onchange action for this form field . * * SEE UTIL * * search - select - has - many . js if selecting models from a hasMany relationship
* @ 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
* @ 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
* @ param { array } [ excludeOptions ] - array of strings containing model ids to filter from the dropdown ( ex : [ 'allow_all' ] )
// * query params for dropdown items
* @ param { array } models - models to fetch from API . models with varying permissions should be ordered from least restricted to anticipated most restricted ( ex . if one model is an enterprise only feature , pass it in last )
// * template only/display args
* @ param { string } id - The name of the form field
* @ param { string } [ label ] - Label appears above the form field
* @ param { string } [ labelClass ] - overwrite default label size ( 14 px ) from class = "is-label"
2022-07-27 21:18:22 +00:00
* @ param { string } [ helpText ] - Text to be displayed in the info tooltip for this form field
* @ param { string } [ subText ] - Text to be displayed below the label
2022-11-19 01:29:04 +00:00
* @ param { string } fallbackComponent - name of component to be rendered if the API call 403 s
2022-07-27 21:18:22 +00:00
* @ param { string } [ placeholder ] - placeholder text to override the default text of "Search"
2022-11-19 01:29:04 +00:00
* @ param { boolean } [ displayInherit = false ] - if you need the search select component to display inherit instead of box .
2022-07-27 21:18:22 +00:00
* /
export default class SearchSelectWithModal extends Component {
@ service store ;
@ tracked shouldUseFallback = false ;
2022-11-19 01:29:04 +00:00
@ tracked selectedOptions = [ ] ; // list of selected options
@ tracked dropdownOptions = [ ] ; // options that will render in dropdown, updates as selections are added/discarded
@ tracked showModal = false ;
@ tracked nameInput = null ;
2022-07-27 21:18:22 +00:00
2022-11-19 01:29:04 +00:00
get hidePowerSelect ( ) {
return this . selectedOptions . length >= this . args . selectLimit ;
2022-07-27 21:18:22 +00:00
}
get shouldRenderName ( ) {
2022-11-19 01:29:04 +00:00
return this . args . models ? . some ( ( model ) => model . includes ( 'identity' ) ) || this . args . shouldRenderName
? true
: false ;
2022-07-27 21:18:22 +00:00
}
2022-11-19 01:29:04 +00:00
addSearchText ( optionsToFormat ) {
// maps over array models from query
return optionsToFormat . toArray ( ) . map ( ( option ) => {
option . searchText = ` ${ option . name } ${ option . id } ` ;
return option ;
} ) ;
2022-07-27 21:18:22 +00:00
}
2022-11-19 01:29:04 +00:00
formatInputAndUpdateDropdown ( inputValues ) {
// inputValues are initially an array of strings from @inputValue
// map over so selectedOptions are objects
return inputValues . map ( ( option ) => {
const matchingOption = this . dropdownOptions . findBy ( 'id' , option ) ;
// remove any matches from dropdown list
this . dropdownOptions . removeObject ( matchingOption ) ;
return {
id : option ,
name : matchingOption ? matchingOption . name : option ,
searchText : matchingOption ? matchingOption . searchText : option ,
} ;
} ) ;
2022-07-27 21:18:22 +00:00
}
2022-11-19 01:29:04 +00:00
@ task
* fetchOptions ( ) {
this . dropdownOptions = [ ] ; // reset dropdown anytime we re-fetch
if ( ! this . args . models ) {
return ;
}
for ( const modelType of this . args . models ) {
try {
// fetch options from the store
let options = yield this . store . query ( modelType , { } ) ;
if ( this . args . excludeOptions ) {
options = options . filter ( ( o ) => ! this . args . excludeOptions . includes ( o . id ) ) ;
2022-07-27 21:18:22 +00:00
}
2022-11-19 01:29:04 +00:00
// add to dropdown options
this . dropdownOptions = [ ... this . dropdownOptions , ... this . addSearchText ( options ) ] ;
} catch ( err ) {
if ( err . httpStatus === 404 ) {
// continue to query other models even if one 404s
// and so selectedOptions will be set after for loop
continue ;
}
if ( err . httpStatus === 403 ) {
// when multiple models are passed in, don't use fallback if the first query is successful
// (i.e. policies ['acl', 'rgp'] - rgp policies are ENT only so will always fail on OSS)
if ( this . dropdownOptions . length > 0 && this . args . models . length > 1 ) continue ;
this . shouldUseFallback = true ;
return ;
}
throw err ;
2022-07-27 21:18:22 +00:00
}
}
2022-11-19 01:29:04 +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 )
: [ ] ;
2022-07-27 21:18:22 +00:00
}
2022-11-19 01:29:04 +00:00
@ action
2022-07-27 21:18:22 +00:00
handleChange ( ) {
if ( this . selectedOptions . length && typeof this . selectedOptions . firstObject === 'object' ) {
2022-11-19 01:29:04 +00:00
this . args . onChange ( Array . from ( this . selectedOptions , ( option ) => option . id ) ) ;
2022-07-27 21:18:22 +00:00
} else {
this . args . onChange ( this . selectedOptions ) ;
}
}
2022-11-19 01:29:04 +00:00
shouldShowCreate ( id , searchResults ) {
if ( searchResults && searchResults . length && searchResults . firstObject . groupName ) {
return ! searchResults . some ( ( group ) => group . options . findBy ( 'id' , id ) ) ;
2022-07-27 21:18:22 +00:00
}
2022-11-09 23:15:31 +00:00
const existingOption =
2022-11-19 01:29:04 +00:00
this . dropdownOptions &&
( this . dropdownOptions . findBy ( 'id' , id ) || this . dropdownOptions . findBy ( 'name' , id ) ) ;
2022-07-27 21:18:22 +00:00
return ! existingOption ;
}
2022-11-19 01:29:04 +00:00
// ----- adapted from ember-power-select-with-create
2022-07-27 21:18:22 +00:00
addCreateOption ( term , results ) {
if ( this . shouldShowCreate ( term , results ) ) {
2022-11-19 01:29:04 +00:00
const name = ` No results found for " ${ term } ". Click here to create it. ` ;
2022-07-27 21:18:22 +00:00
const suggestion = {
_ _isSuggestion _ _ : true ,
_ _value _ _ : term ,
name ,
id : name ,
} ;
results . unshift ( suggestion ) ;
}
}
2022-11-19 01:29:04 +00:00
2022-07-27 21:18:22 +00:00
filter ( options , searchText ) {
const matcher = ( option , text ) => defaultMatcher ( option . searchText , text ) ;
return filterOptions ( options || [ ] , searchText , matcher ) ;
}
// -----
@ action
discardSelection ( selected ) {
this . selectedOptions . removeObject ( selected ) ;
2022-11-19 01:29:04 +00:00
this . dropdownOptions . pushObject ( selected ) ;
2022-07-27 21:18:22 +00:00
this . handleChange ( ) ;
}
2022-11-19 01:29:04 +00:00
2022-07-27 21:18:22 +00:00
// ----- adapted from ember-power-select-with-create
@ action
2022-11-19 01:29:04 +00:00
searchAndSuggest ( term ) {
2022-07-27 21:18:22 +00:00
if ( term . length === 0 ) {
2022-11-19 01:29:04 +00:00
return this . dropdownOptions ;
2022-07-27 21:18:22 +00:00
}
2022-11-19 01:29:04 +00:00
if ( this . args . models ? . some ( ( model ) => model . includes ( 'policy' ) ) ) {
term = term . toLowerCase ( ) ;
2022-07-27 21:18:22 +00:00
}
2022-11-19 01:29:04 +00:00
const newOptions = this . filter ( this . dropdownOptions , term ) ;
2022-07-27 21:18:22 +00:00
this . addCreateOption ( term , newOptions ) ;
return newOptions ;
}
2022-11-19 01:29:04 +00:00
2022-07-27 21:18:22 +00:00
@ action
2022-11-19 01:29:04 +00:00
selectOrCreate ( selection ) {
2022-07-27 21:18:22 +00:00
if ( selection && selection . _ _isSuggestion _ _ ) {
2022-11-19 01:29:04 +00:00
// user has clicked to create a new item
// wait to handleChange below in resetModal
this . nameInput = selection . _ _value _ _ ; // input is passed to form component
2022-07-27 21:18:22 +00:00
this . showModal = true ;
} else {
2022-11-19 01:29:04 +00:00
// user has selected an existing item, handleChange immediately
2022-07-27 21:18:22 +00:00
this . selectedOptions . pushObject ( selection ) ;
2022-11-19 01:29:04 +00:00
this . dropdownOptions . removeObject ( selection ) ;
2022-07-27 21:18:22 +00:00
this . handleChange ( ) ;
}
}
// -----
@ action
resetModal ( model ) {
2022-11-19 01:29:04 +00:00
// resetModal fires when the form component calls onSave or onCancel
2022-07-27 21:18:22 +00:00
this . showModal = false ;
if ( model && model . currentState . isSaved ) {
const { name } = model ;
this . selectedOptions . pushObject ( { name , id : name } ) ;
this . handleChange ( ) ;
}
2022-11-19 01:29:04 +00:00
this . nameInput = null ;
2022-07-27 21:18:22 +00:00
}
}