< script lang = "ts" setup >
import type { Select as AntSelect } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { SelectOptionType } from 'nocodb-sdk'
import type { FormFieldsLimitOptionsType } from '~/lib'
import {
ActiveCellInj ,
ColumnInj ,
EditColumnInj ,
EditModeInj ,
IsFormInj ,
IsKanbanInj ,
IsSurveyFormInj ,
ReadonlyInj ,
computed ,
enumColor ,
extractSdkResponseErrorMsg ,
iconMap ,
inject ,
isDrawerOrModalExist ,
ref ,
useBase ,
useEventListener ,
useRoles ,
useSelectedCellKeyupListener ,
watch ,
} from '#imports'
interface Props {
modelValue ? : string | undefined
rowIndex ? : number
disableOptionCreation ? : boolean
}
const { modelValue , disableOptionCreation } = defineProps < Props > ( )
const emit = defineEmits ( [ 'update:modelValue' ] )
const { isMobileMode } = useGlobal ( )
const column = inject ( ColumnInj ) !
const readOnly = inject ( ReadonlyInj ) !
const isEditable = inject ( EditModeInj , ref ( false ) )
const activeCell = inject ( ActiveCellInj , ref ( false ) )
const isForm = inject ( IsFormInj , ref ( false ) )
// use both ActiveCellInj or EditModeInj to determine the active state
// since active will be false in case of form view
const active = computed ( ( ) => activeCell . value || isEditable . value || isForm . value )
const aselect = ref < typeof AntSelect > ( )
const isOpen = ref ( false )
const isKanban = inject ( IsKanbanInj , ref ( false ) )
const isPublic = inject ( IsPublicInj , ref ( false ) )
const isEditColumn = inject ( EditColumnInj , ref ( false ) )
const isSurveyForm = inject ( IsSurveyFormInj , ref ( false ) )
const { $api } = useNuxtApp ( )
const searchVal = ref ( )
const { getMeta } = useMetas ( )
const { isUIAllowed } = useRoles ( )
const { isPg , isMysql } = useBase ( )
// a variable to keep newly created option value
// temporary until it's add the option to column meta
const tempSelectedOptState = ref < string > ( )
const isFocusing = ref ( false )
const isNewOptionCreateEnabled = computed ( ( ) => ! isPublic . value && ! disableOptionCreation && isUIAllowed ( 'fieldEdit' ) )
const options = computed < ( SelectOptionType & { value : string } ) [ ] > ( ( ) => {
if ( column ? . value . colOptions ) {
const opts = column . value . colOptions
? // todo: fix colOptions type, options does not exist as a property
( column . value . colOptions as any ) . options . filter ( ( el : SelectOptionType ) => el . title !== '' ) || [ ]
: [ ]
for ( const op of opts . filter ( ( el : any ) => el . order === null ) ) {
op . title = op . title . replace ( /^'/ , '' ) . replace ( /'$/ , '' )
}
let order = 1
const limitOptionsById =
( ( parseProp ( column . value . meta ) ? . limitOptions || [ ] ) . reduce (
( o : Record < string , FormFieldsLimitOptionsType > , f : FormFieldsLimitOptionsType ) => {
if ( order < ( f ? . order ? ? 0 ) ) {
order = f . order
}
return {
... o ,
[ f . id ] : f ,
}
} ,
{ } ,
) as Record < string , FormFieldsLimitOptionsType > ) ? ? { }
if (
! isEditColumn . value &&
isForm . value &&
parseProp ( column . value . meta ) ? . isLimitOption &&
( parseProp ( column . value . meta ) ? . limitOptions || [ ] ) . length
) {
return opts
. filter ( ( o : SelectOptionType & { value : string } ) => {
if ( limitOptionsById [ o . id ] ? . show !== undefined ) {
return limitOptionsById [ o . id ] ? . show
}
return false
} )
. map ( ( o : any ) => ( {
... o ,
value : o . title ,
order : o . id && limitOptionsById [ o . id ] ? limitOptionsById [ o . id ] ? . order : order ++ ,
} ) )
. sort ( ( a , b ) => a . order - b . order )
} else {
return opts . map ( ( o : any ) => ( { ... o , value : o . title } ) )
}
}
return [ ]
} )
const isOptionMissing = computed ( ( ) => {
return ( options . value ? ? [ ] ) . every ( ( op ) => op . title !== searchVal . value )
} )
const hasEditRoles = computed ( ( ) => isUIAllowed ( 'dataEdit' ) || isForm . value )
const editAllowed = computed ( ( ) => ( hasEditRoles . value || isForm . value ) && active . value )
const vModel = computed ( {
get : ( ) => tempSelectedOptState . value ? ? modelValue ,
set : ( val ) => {
if ( val && isNewOptionCreateEnabled . value && ( options . value ? ? [ ] ) . every ( ( op ) => op . title !== val ) ) {
tempSelectedOptState . value = val
return addIfMissingAndSave ( )
}
emit ( 'update:modelValue' , val || null )
} ,
} )
watch ( isOpen , ( n , _o ) => {
if ( editAllowed . value ) {
if ( ! n ) {
aselect . value ? . $el ? . querySelector ( 'input' ) ? . blur ( )
} else {
aselect . value ? . $el ? . querySelector ( 'input' ) ? . focus ( )
}
}
} )
useSelectedCellKeyupListener ( activeCell , ( e ) => {
switch ( e . key ) {
case 'Escape' :
isOpen . value = false
break
case 'Enter' :
if ( editAllowed . value && active . value && ! isOpen . value ) {
isOpen . value = true
}
break
// skip space bar key press since it's used for expand row
case ' ' :
break
default :
if ( ! editAllowed . value ) {
e . preventDefault ( )
break
}
// toggle only if char key pressed
if ( ! ( e . metaKey || e . ctrlKey || e . shiftKey || e . altKey ) && e . key ? . length === 1 && ! isDrawerOrModalExist ( ) ) {
e . stopPropagation ( )
isOpen . value = true
}
break
}
} )
async function addIfMissingAndSave ( ) {
if ( ! tempSelectedOptState . value || isPublic . value ) return false
const newOptValue = tempSelectedOptState . value
searchVal . value = ''
tempSelectedOptState . value = undefined
if ( newOptValue && ! options . value . some ( ( o ) => o . title === newOptValue ) ) {
try {
options . value . push ( {
title : newOptValue ,
value : newOptValue ,
color : enumColor . light [ ( options . value . length + 1 ) % enumColor . light . length ] ,
} )
column . value . colOptions = { options : options . value . map ( ( { value : _ , ... rest } ) => rest ) }
const updatedColMeta = { ... column . value }
// todo: refactor and avoid repetition
if ( updatedColMeta . cdf ) {
// Postgres returns default value wrapped with single quotes & casted with type so we have to get value between single quotes to keep it unified for all databases
if ( isPg ( column . value . source _id ) ) {
updatedColMeta . cdf = updatedColMeta . cdf . substring (
updatedColMeta . cdf . indexOf ( ` ' ` ) + 1 ,
updatedColMeta . cdf . lastIndexOf ( ` ' ` ) ,
)
}
// Mysql escapes single quotes with backslash so we keep quotes but others have to unescaped
if ( ! isMysql ( column . value . source _id ) && ! isPg ( column . value . source _id ) ) {
updatedColMeta . cdf = updatedColMeta . cdf . replace ( /''/g , "'" )
}
}
await $api . dbTableColumn . update (
( column . value as { fk _column _id ? : string } ) ? . fk _column _id || ( column . value ? . id as string ) ,
updatedColMeta ,
)
vModel . value = newOptValue
await getMeta ( column . value . fk _model _id ! , true )
} catch ( e : any ) {
console . log ( e )
message . error ( await extractSdkResponseErrorMsg ( e ) )
}
}
}
const search = ( ) => {
if ( isMobileMode . value ) return
searchVal . value = aselect . value ? . $el ? . querySelector ( '.ant-select-selection-search-input' ) ? . value
}
// prevent propagation of keydown event if select is open
const onKeydown = ( e : KeyboardEvent ) => {
if ( isOpen . value && active . value ) {
e . stopPropagation ( )
}
if ( e . key === 'Enter' ) {
e . stopPropagation ( )
}
if ( e . key === 'Escape' ) {
isOpen . value = false
}
}
const handleKeyDownList = ( e : KeyboardEvent ) => {
switch ( e . key ) {
case 'ArrowUp' :
case 'ArrowDown' :
case 'ArrowRight' :
case 'ArrowLeft' :
// skip
e . stopPropagation ( )
break
}
}
const onSelect = ( ) => {
isOpen . value = false
isEditable . value = false
}
const toggleMenu = ( e : Event ) => {
// todo: refactor
// check clicked element is clear icon
if (
( e . target as HTMLElement ) ? . classList . contains ( 'ant-select-clear' ) ||
( e . target as HTMLElement ) ? . closest ( '.ant-select-clear' )
) {
vModel . value = ''
return e . stopPropagation ( )
}
if ( isFocusing . value ) return
isOpen . value = editAllowed . value && ! isOpen . value
}
const handleClose = ( e : MouseEvent ) => {
if ( isOpen . value && aselect . value && ! aselect . value . $el . contains ( e . target ) ) {
isOpen . value = false
}
}
useEventListener ( document , 'click' , handleClose , true )
const selectedOpt = computed ( ( ) => {
return options . value . find ( ( o ) => o . value === vModel . value || o . value === vModel . value ? . toString ( ) ? . trim ( ) )
} )
const onFocus = ( ) => {
isFocusing . value = true
setTimeout ( ( ) => {
isFocusing . value = false
} , 250 )
if ( isSurveyForm . value && vModel . value ) return
isOpen . value = true
}
< / script >
< template >
< div
class = "nc-cell-field h-full w-full flex items-center nc-single-select focus:outline-transparent"
: class = "{ 'read-only': readOnly, 'max-w-full': isForm }"
@ click = "toggleMenu"
@ keydown . enter . stop . prevent = "toggleMenu"
>
< div v-if ="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full" >
< a -radio -group
v - model : value = "vModel"
: disabled = "readOnly || !editAllowed"
class = "nc-field-layout-list"
@ keydown = "handleKeyDownList"
@ click . stop
>
< a -radio
v - for = "op of options"
: key = "op.title"
: value = "op.title"
: data - testid = "`select-option-${column.title}-${rowIndex}`"
: class = "`nc-select-option-${column.title}-${op.title}`"
>
< a -tag class = "rounded-tag max-w-full" :color ="op.color" >
< span
: style = " {
'color' : tinycolor . isReadable ( op . color || '#ccc' , '#fff' , { level : 'AA' , size : 'large' } )
? '#fff'
: tinycolor . mostReadable ( op . color || '#ccc' , [ '#0b1d05' , '#fff' ] ) . toHex8String ( ) ,
'font-size' : '13px' ,
} "
>
< NcTooltip class = "truncate max-w-full" show -on -truncate -only >
< template # title >
{ { op . title } }
< / template >
< span
class = "text-ellipsis overflow-hidden"
: style = " {
wordBreak : 'keep-all' ,
whiteSpace : 'nowrap' ,
display : 'inline' ,
} "
>
{ { op . title } }
< / span >
< / NcTooltip >
< / span >
< / a - t a g > < / a - r a d i o
>
< / a - r a d i o - g r o u p >
< div
v - if = "vModel"
class = "inline-block px-2 pt-2 cursor-pointer text-xs text-gray-500 hover:text-gray-800"
@ click = "vModel = ''"
>
{ { $t ( 'labels.clearSelection' ) } }
< / div >
< / div >
< template v-else >
< div v-if ="!(active || isEditable)" class="w-full" >
< a -tag v-if ="selectedOpt" class="rounded-tag max-w-full" :color="selectedOpt.color" >
< span
: style = " {
'color' : tinycolor . isReadable ( selectedOpt . color || '#ccc' , '#fff' , { level : 'AA' , size : 'large' } )
? '#fff'
: tinycolor . mostReadable ( selectedOpt . color || '#ccc' , [ '#0b1d05' , '#fff' ] ) . toHex8String ( ) ,
'font-size' : '13px' ,
} "
: class = "{ 'text-sm': isKanban }"
>
< NcTooltip class = "truncate max-w-full" show -on -truncate -only >
< template # title >
{ { selectedOpt . title } }
< / template >
< span
class = "text-ellipsis overflow-hidden"
: style = " {
wordBreak : 'keep-all' ,
whiteSpace : 'nowrap' ,
display : 'inline' ,
} "
>
{ { selectedOpt . title } }
< / span >
< / NcTooltip >
< / span >
< / a - t a g >
< / div >
< NcSelect
v - else
ref = "aselect"
v - model : value = "vModel"
class = "w-full overflow-hidden xs:min-h-12"
: class = "{ 'caret-transparent': !hasEditRoles }"
: placeholder = "isEditColumn ? $t('labels.optional') : ''"
: allow - clear = "!column.rqd && editAllowed"
: bordered = "false"
: open = "isOpen && editAllowed"
: disabled = "readOnly || !editAllowed"
: show - search = "!isMobileMode && isOpen && active"
: show - arrow = "hasEditRoles && !readOnly && active && (vModel === null || vModel === undefined)"
: dropdown - class - name = "`nc-dropdown-single-select-cell !min-w-200px ${isOpen && active ? 'active' : ''}`"
: dropdown - match - select - width = "true"
@ select = "onSelect"
@ keydown = "onKeydown($event)"
@ search = "search"
@ blur = "isOpen = false"
@ focus = "onFocus"
>
< a -select -option
v - for = "op of options"
: key = "op.title"
: value = "op.title"
: data - testid = "`select-option-${column.title}-${rowIndex}`"
: class = "`nc-select-option-${column.title}-${op.title}`"
@ click . stop
>
< a -tag class = "rounded-tag max-w-full" :color ="op.color" >
< span
: style = " {
'color' : tinycolor . isReadable ( op . color || '#ccc' , '#fff' , { level : 'AA' , size : 'large' } )
? '#fff'
: tinycolor . mostReadable ( op . color || '#ccc' , [ '#0b1d05' , '#fff' ] ) . toHex8String ( ) ,
'font-size' : '13px' ,
} "
: class = "{ 'text-sm': isKanban }"
>
< NcTooltip class = "truncate max-w-full" show -on -truncate -only >
< template # title >
{ { op . title } }
< / template >
< span
class = "text-ellipsis overflow-hidden"
: style = " {
wordBreak : 'keep-all' ,
whiteSpace : 'nowrap' ,
display : 'inline' ,
} "
>
{ { op . title } }
< / span >
< / NcTooltip >
< / span >
< / a - t a g >
< / a - s e l e c t - o p t i o n >
< a -select -option v-if ="searchVal && isOptionMissing && isNewOptionCreateEnabled" :key="searchVal" :value ="searchVal" >
< div class = "flex gap-2 text-gray-500 items-center h-full" >
< component :is ="iconMap.plusThick" class = "min-w-4" / >
< div class = "text-xs whitespace-normal" >
{ { $t ( 'msg.selectOption.createNewOptionNamed' ) } } < strong > { { searchVal } } < / strong >
< / div >
< / div >
< / a - s e l e c t - o p t i o n >
< / NcSelect >
< / template >
< / div >
< / template >
< style scoped lang = "scss" >
. rounded - tag {
@ apply py - 0 px - [ 12 px ] rounded - [ 12 px ] ;
}
: deep ( . ant - tag ) {
@ apply "rounded-tag" my - [ 2 px ] ;
}
: deep ( . ant - select - clear ) {
opacity : 1 ;
border - radius : 100 % ;
}
. nc - single - select : not ( . read - only ) {
: deep ( . ant - select - selector ) ,
: deep ( . ant - select - selector input ) {
@ apply ! cursor - pointer ;
}
}
: deep ( . ant - select - selector ) {
@ apply ! pl - 0 ! pr - 4 ;
}
: deep ( . ant - select - selector . ant - select - selection - item ) {
@ apply flex items - center ;
text - overflow : clip ;
}
: deep ( . ant - select - selection - search - input ) {
@ apply ! text - xs ;
}
: deep ( . ant - select - clear > span ) {
@ apply block ;
}
< / style >
< style lang = "scss" >
. ant - select - item - option - content {
@ apply ! flex ! items - center ;
}
< / style >