< script setup lang = "ts" >
import Draggable from 'vuedraggable'
import { UITypes } from 'nocodb-sdk'
import {
IsKanbanInj ,
enumColor ,
iconMap ,
isEeUI ,
onMounted ,
storeToRefs ,
useColumnCreateStoreOrThrow ,
useVModel ,
watch ,
} from '#imports'
interface Option {
color : string
title : string
id ? : string
fk _colum _id ? : string
order ? : number
}
const props = defineProps < {
value : any
} > ( )
const emit = defineEmits ( [ 'update:value' ] )
const vModel = useVModel ( props , 'value' , emit )
const { formState , setAdditionalValidations , validateInfos , isPg , isMysql } = useColumnCreateStoreOrThrow ( )
const { project } = storeToRefs ( useProject ( ) )
const { loadMagic , optionsMagic : _optionsMagic } = useNocoEe ( )
const options = ref < ( Option & { status ? : 'remove' } ) [ ] > ( [ ] )
const renderedOptions = ref < ( Option & { status ? : 'remove' } ) [ ] > ( [ ] )
const savedDefaultOption = ref < Option | null > ( null )
const savedCdf = ref < string | null > ( null )
const colorMenus = ref < any > ( { } )
const colors = ref ( enumColor . light )
const inputs = ref ( )
const defaultOption = ref ( )
const isKanban = inject ( IsKanbanInj , ref ( false ) )
const validators = {
colOptions : [
{
type : 'object' ,
fields : {
options : {
validator : ( _ : any , _opt : any ) => {
return new Promise < void > ( ( resolve , reject ) => {
for ( const opt of options . value ) {
if ( ( opt as any ) . status === 'remove' ) continue
if ( ! opt . title . length ) {
return reject ( new Error ( "Select options can't be null" ) )
}
if ( vModel . value . uidt === UITypes . MultiSelect && opt . title . includes ( ',' ) ) {
return reject ( new Error ( "MultiSelect columns can't have commas(',')" ) )
}
if ( options . value . filter ( ( el ) => el . title === opt . title && ( el as any ) . status !== 'remove' ) . length > 1 ) {
return reject ( new Error ( "Select options can't have duplicates" ) )
}
}
resolve ( )
} )
} ,
} ,
} ,
} ,
] ,
}
// we use a correct syntax from async-validator but causes a type mismatch on antdv so we cast any
setAdditionalValidations ( {
... validators ,
} as any )
onMounted ( ( ) => {
if ( ! vModel . value . colOptions ? . options ) {
vModel . value . colOptions = {
options : [ ] ,
}
}
options . value = vModel . value . colOptions . options
renderedOptions . value = [ ... options . value ]
// Support for older options
for ( const op of options . value . filter ( ( el ) => el . order === null ) ) {
op . title = op . title . replace ( /^'/ , '' ) . replace ( /'$/ , '' )
}
if ( vModel . value . 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 . value ) {
vModel . value . cdf = vModel . value . cdf . substring ( vModel . value . cdf . indexOf ( ` ' ` ) + 1 , vModel . value . cdf . lastIndexOf ( ` ' ` ) )
}
// Mysql escapes single quotes with backslash so we keep quotes but others have to unescaped
if ( ! isMysql . value ) {
vModel . value . cdf = vModel . value . cdf . replace ( /''/g , "'" )
}
}
const fndDefaultOption = options . value . find ( ( el ) => el . title === vModel . value . cdf )
if ( fndDefaultOption ) {
defaultOption . value = fndDefaultOption
}
} )
const optionChanged = ( changedId : string ) => {
if ( changedId && changedId === defaultOption . value ? . id ) {
vModel . value . cdf = defaultOption . value . title
}
}
const getNextColor = ( ) => {
let tempColor = colors . value [ 0 ]
if ( options . value . length && options . value [ options . value . length - 1 ] . color ) {
const lastColor = colors . value . indexOf ( options . value [ options . value . length - 1 ] . color )
tempColor = colors . value [ ( lastColor + 1 ) % colors . value . length ]
}
return tempColor
}
const addNewOption = ( ) => {
const tempOption = {
title : '' ,
color : getNextColor ( ) ,
}
renderedOptions . value . push ( tempOption )
options . value . push ( tempOption )
}
const optionsMagic = async ( ) => {
await _optionsMagic ( project , formState , getNextColor , options . value , renderedOptions . value )
}
const syncOptions = ( ) => {
vModel . value . colOptions . options = renderedOptions . value . filter ( ( op ) => op . status !== 'remove' )
}
const removeRenderedOption = ( index : number ) => {
renderedOptions . value [ index ] . status = 'remove'
syncOptions ( )
const optionId = renderedOptions . value [ index ] ? . id
if ( optionId === defaultOption . value ? . id ) {
savedDefaultOption . value = { ... defaultOption . value }
savedCdf . value = vModel . value . cdf
defaultOption . value = null
vModel . value . cdf = null
}
}
const undoRemoveRenderedOption = ( index : number ) => {
renderedOptions . value [ index ] . status = undefined
syncOptions ( )
const optionId = renderedOptions . value [ index ] ? . id
if ( optionId === savedDefaultOption . value ? . id ) {
defaultOption . value = { ... savedDefaultOption . value }
vModel . value . cdf = savedCdf . value
savedDefaultOption . value = null
savedCdf . value = null
}
}
// focus last created input
watch ( inputs , ( ) => {
if ( inputs . value ? . $el ) {
inputs . value . $el . focus ( )
}
} )
< / script >
< template >
< div class = "w-full" >
< div class = "max-h-[250px] overflow-x-auto scrollbar-thin-dull" >
< Draggable :list ="renderedOptions" item -key = " id " handle = ".nc-child-draggable-icon" @change ="syncOptions" >
< template # item = "{ element, index }" >
< div class = "flex py-1 items-center nc-select-option" >
< div
class = "flex items-center w-full"
: data - testid = "`select-column-option-${index}`"
: class = "{ removed: element.status === 'remove' }"
>
< component
: is = "iconMap.dragVertical"
v - if = "!isKanban"
small
class = "nc-child-draggable-icon handle"
: data - testid = "`select-option-column-handle-icon-${element.title}`"
/ >
< a -dropdown
v - model : visible = "colorMenus[index]"
: trigger = "['click']"
overlay - class - name = "nc-dropdown-select-color-options"
>
< template # overlay >
< LazyGeneralColorPicker
v - model = "element.color"
: pick - button = "true"
@ update : model - value = "colorMenus[index] = false"
/ >
< / template >
< MdiArrowDownDropCircle
class = "mr-2 text-[1.5em] outline-0 hover:!text-[1.75em]"
: class = "{ 'text-[1.75em]': colorMenus[index] }"
: style = "{ color: element.color }"
/ >
< / a - d r o p d o w n >
< a -input
ref = "inputs"
v - model : value = "element.title"
class = "caption !rounded-lg"
: data - testid = "`select-column-option-input-${index}`"
: disabled = "element.status === 'remove'"
@ keydown . enter . prevent = "element.title?.trim() && addNewOption()"
@ change = "optionChanged(element.id)"
/ >
< / div >
< div
v - if = "element.status !== 'remove'"
: data - testid = "`select-column-option-remove-${index}`"
class = "ml-1 hover:!text-black-500 text-gray-500 cursor-pointer hover:bg-gray-50 py-1 px-1.5 rounded-md"
@ click = "removeRenderedOption(index)"
>
< component :is ="iconMap.close" class = "-mt-0.25" / >
< / div >
< MdiArrowULeftBottom
v - else
class = "ml-2 hover:!text-black-500 text-gray-500 cursor-pointer"
: data - testid = "`select-column-option-remove-undo-${index}`"
@ click = "undoRemoveRenderedOption(index)"
/ >
< / div >
< / template >
< / Draggable >
< / div >
< div v-if ="validateInfos?.colOptions?.help?.[0]?.[0]" class="text-error text-[10px] mb-1 mt-2" >
{ { validateInfos . colOptions . help [ 0 ] [ 0 ] } }
< / div >
< a -button type = "dashed" class = "w-full caption mt-2" @click ="addNewOption()" >
< div class = "flex items-center" >
< component :is ="iconMap.plus" / >
< span class = "flex-auto" > Add option < / span >
< / div >
< / a - b u t t o n >
< div v-if ="isEeUI" class="w-full cursor-pointer" @click="optionsMagic()" >
< GeneralIcon icon = "magic" : class = "{ 'nc-animation-pulse': loadMagic }" class = "w-full flex mt-2 text-orange-400" / >
< / div >
< / div >
< / template >
< style scoped >
. removed {
position : relative ;
}
. removed : after {
position : absolute ;
left : 0 ;
top : 50 % ;
height : 1 px ;
background : # ccc ;
content : '' ;
width : calc ( 100 % + 5 px ) ;
display : block ;
}
< / style >