@ -1,6 +1,7 @@
< script setup lang = "ts" >
< script setup lang = "ts" >
import Draggable from 'vuedraggable'
import Draggable from 'vuedraggable'
import { UITypes } from 'nocodb-sdk'
import tinycolor from 'tinycolor2'
import { type SelectOptionsType , UITypes } from 'nocodb-sdk'
import InfiniteLoading from 'v3-infinite-loading'
import InfiniteLoading from 'v3-infinite-loading'
interface Option {
interface Option {
@ -9,20 +10,25 @@ interface Option {
id ? : string
id ? : string
fk _colum _id ? : string
fk _colum _id ? : string
order ? : number
order ? : number
status ? : 'remove'
status ? : 'remove' | 'new'
index ? : number
index ? : number
}
}
const props = defineProps < {
const props = defineProps < {
value : any
value : any
fromTableExplorer ? : boolean
fromTableExplorer ? : boolean
isKanbanStack ? : boolean
optionId ? : string
isNewStack ? : boolean
} > ( )
} > ( )
const emit = defineEmits ( [ 'update:value' ] )
const emit = defineEmits ( [ 'update:value' , 'saveChanges' ] )
const vModel = useVModel ( props , 'value' , emit )
const vModel = useVModel ( props , 'value' , emit )
const { setAdditionalValidations , validateInfos } = useColumnCreateStoreOrThrow ( )
const { isKanbanStack , optionId , isNewStack } = toRefs ( props )
const { setAdditionalValidations , validateInfos , column } = useColumnCreateStoreOrThrow ( )
/ / c o n s t { b a s e } = s t o r e T o R e f s ( u s e B a s e ( ) )
/ / c o n s t { b a s e } = s t o r e T o R e f s ( u s e B a s e ( ) )
@ -61,7 +67,7 @@ const validators = {
validator : ( _ : any , _opt : any ) => {
validator : ( _ : any , _opt : any ) => {
return new Promise < void > ( ( resolve , reject ) => {
return new Promise < void > ( ( resolve , reject ) => {
for ( const opt of options . value ) {
for ( const opt of options . value ) {
if ( ( opt as any ) . status === 'remove' ) continue
if ( ( opt as any ) . status === 'remove' || ( opt as any ) . status === 'new' ) continue
if ( ! opt . title . length ) {
if ( ! opt . title . length ) {
return reject ( new Error ( t ( 'msg.selectOption.cantBeNull' ) ) )
return reject ( new Error ( t ( 'msg.selectOption.cantBeNull' ) ) )
@ -87,43 +93,13 @@ setAdditionalValidations({
... validators ,
... validators ,
} as any )
} as any )
onMounted ( ( ) => {
const kanbanStackOption = computed ( ( ) => {
if ( ! vModel . value . colOptions ? . options ) {
if ( isNewStack . value ) {
vModel . value . colOptions = {
return renderedOptions . value [ renderedOptions . value . length - 1 ]
options : [ ] ,
} else if ( optionId . value ) {
}
return renderedOptions . value . find ( ( o ) => o . id === optionId . value )
}
isReverseLazyLoad . value = false
options . value = vModel . value . colOptions . options
let indexCounter = 0
options . value . map ( ( el ) => {
el . index = indexCounter ++
return el
} )
loadedOptionAnchor . value = Math . min ( loadedOptionAnchor . value , options . value . length )
renderedOptions . value = [ ... options . value ] . slice ( 0 , loadedOptionAnchor . value )
/ / S u p p o r t f o r o l d e r o p t i o n s
for ( const op of options . value . filter ( ( el ) => el . order === null ) ) {
op . title = op . title . replace ( /^'/ , '' ) . replace ( /'$/ , '' )
}
if ( vModel . value . cdf && typeof vModel . value . cdf === 'string' ) {
const fndDefaultOption = options . value . filter ( ( el ) => el . title === vModel . value . cdf )
if ( ! fndDefaultOption . length ) {
vModel . value . cdf = vModel . value . cdf . replace ( /^'/ , '' ) . replace ( /'$/ , '' )
}
}
const fndDefaultOption = options . value . filter ( ( el ) => el . title === vModel . value . cdf )
if ( fndDefaultOption . length ) {
defaultOption . value = vModel . value . uidt === UITypes . SingleSelect ? [ fndDefaultOption [ 0 ] ] : fndDefaultOption
}
}
return null
} )
} )
const getNextColor = ( ) => {
const getNextColor = ( ) => {
@ -142,15 +118,20 @@ const addNewOption = () => {
title : '' ,
title : '' ,
color : getNextColor ( ) ,
color : getNextColor ( ) ,
index : options . value . length ,
index : options . value . length ,
... ( isKanbanStack . value ? { status : 'new' } : { } ) ,
}
}
options . value . push ( tempOption )
options . value . push ( tempOption as Option )
isReverseLazyLoad . value = true
if ( isKanbanStack . value ) {
renderedOptions . value = options . value
} else {
isReverseLazyLoad . value = true
loadedOptionAnchor . value = options . value . length - OPTIONS _PAGE _COUNT
loadedOptionAnchor . value = options . value . length - OPTIONS _PAGE _COUNT
loadedOptionAnchor . value = Math . max ( loadedOptionAnchor . value , 0 )
loadedOptionAnchor . value = Math . max ( loadedOptionAnchor . value , 0 )
renderedOptions . value = options . value . slice ( loadedOptionAnchor . value , options . value . length )
renderedOptions . value = options . value . slice ( loadedOptionAnchor . value , options . value . length )
}
optionsWrapperDomRef . value ! . scrollTop = optionsWrapperDomRef . value ! . scrollHeight
optionsWrapperDomRef . value ! . scrollTop = optionsWrapperDomRef . value ! . scrollHeight
@ -174,7 +155,7 @@ const addNewOption = () => {
/ / a w a i t _ o p t i o n s M a g i c ( b a s e , f o r m S t a t e , g e t N e x t C o l o r , o p t i o n s . v a l u e , r e n d e r e d O p t i o n s . v a l u e )
/ / a w a i t _ o p t i o n s M a g i c ( b a s e , f o r m S t a t e , g e t N e x t C o l o r , o p t i o n s . v a l u e , r e n d e r e d O p t i o n s . v a l u e )
/ / }
/ / }
const syncOptions = ( ) => {
const syncOptions = ( saveChanges : boolean = false , submit : boolean = false , payload ? : Option ) => {
/ / s e t i n i t i a l c o l O p t i o n s i f n o t s e t
/ / s e t i n i t i a l c o l O p t i o n s i f n o t s e t
vModel . value . colOptions = vModel . value . colOptions || { }
vModel . value . colOptions = vModel . value . colOptions || { }
vModel . value . colOptions . options = options . value
vModel . value . colOptions . options = options . value
@ -189,6 +170,10 @@ const syncOptions = () => {
const { status : _s , ... rest } = op
const { status : _s , ... rest } = op
return rest
return rest
} )
} )
if ( saveChanges ) {
emit ( 'saveChanges' , submit , true , payload )
}
}
}
const removeRenderedOption = ( index : number ) => {
const removeRenderedOption = ( index : number ) => {
@ -220,7 +205,7 @@ const removeRenderedOption = (index: number) => {
}
}
}
}
const optionChanged = ( changedElement : Option ) => {
const optionChanged = ( changedElement : Option , saveChanges : boolean = false ) => {
const changedDefaultOptionIndex = defaultOption . value . findIndex ( ( o ) => {
const changedDefaultOptionIndex = defaultOption . value . findIndex ( ( o ) => {
if ( o . id !== undefined && changedElement . id !== undefined ) {
if ( o . id !== undefined && changedElement . id !== undefined ) {
return o . id === changedElement . id
return o . id === changedElement . id
@ -238,7 +223,7 @@ const optionChanged = (changedElement: Option) => {
vModel . value . cdf = defaultOption . value . map ( ( o ) => o . title ) . join ( ',' )
vModel . value . cdf = defaultOption . value . map ( ( o ) => o . title ) . join ( ',' )
}
}
}
}
syncOptions ( )
syncOptions ( saveChanges )
}
}
const undoRemoveRenderedOption = ( index : number ) => {
const undoRemoveRenderedOption = ( index : number ) => {
@ -344,112 +329,261 @@ const loadListData = async ($state: any) => {
$state . loaded ( )
$state . loaded ( )
}
}
onMounted ( ( ) => {
if ( ! vModel . value . colOptions ? . options ) {
vModel . value . colOptions = {
options : [ ] ,
}
}
isReverseLazyLoad . value = false
options . value = vModel . value . colOptions . options
let indexCounter = 0
options . value . map ( ( el ) => {
el . index = indexCounter ++
return el
} )
if ( isKanbanStack . value ) {
renderedOptions . value = options . value
} else {
loadedOptionAnchor . value = Math . min ( loadedOptionAnchor . value , options . value . length )
renderedOptions . value = [ ... options . value ] . slice ( 0 , loadedOptionAnchor . value )
}
/ / S u p p o r t f o r o l d e r o p t i o n s
for ( const op of options . value . filter ( ( el ) => el . order === null ) ) {
op . title = op . title . replace ( /^'/ , '' ) . replace ( /'$/ , '' )
}
if ( vModel . value . cdf && typeof vModel . value . cdf === 'string' ) {
const fndDefaultOption = options . value . filter ( ( el ) => el . title === vModel . value . cdf )
if ( ! fndDefaultOption . length ) {
vModel . value . cdf = vModel . value . cdf . replace ( /^'/ , '' ) . replace ( /'$/ , '' )
}
}
const fndDefaultOption = options . value . filter ( ( el ) => el . title === vModel . value . cdf )
if ( fndDefaultOption . length ) {
defaultOption . value = vModel . value . uidt === UITypes . SingleSelect ? [ fndDefaultOption [ 0 ] ] : fndDefaultOption
}
if ( isKanbanStack . value && isNewStack . value ) {
addNewOption ( )
} else if ( isKanbanStack . value ) {
nextTick ( ( ) => {
setTimeout ( ( ) => {
const doms = document . querySelectorAll ( ` .nc-col-option-select-option .nc-select-col-option-select-option ` )
const dom = doms [ doms . length - 1 ] as HTMLInputElement
if ( dom ) {
dom . focus ( )
}
} , 150 )
} )
}
} )
if ( isKanbanStack . value ) {
onClickOutside ( optionsWrapperDomRef , ( e ) => {
if ( ! kanbanStackOption . value || ( e . target as HTMLElement ) ? . closest ( ` .nc-select-option-color-picker ` ) ) return
const option = ( column . value ? . colOptions as SelectOptionsType ) ? . options ? . find (
( o ) => o ? . id && o . id === kanbanStackOption . value ? . id ,
)
if ( option ? . title !== kanbanStackOption . value ? . title || option ? . color !== kanbanStackOption . value ? . color ) {
syncOptions ( true , true , kanbanStackOption . value )
} else {
emit ( 'saveChanges' , true , false )
}
} )
}
< / script >
< / script >
< template >
< template >
< div class = "w-full" >
< div class = "w-full" >
< div
< div
ref = "optionsWrapperDomRef"
ref = "optionsWrapperDomRef"
class = "nc-col-option-select-option overflow-x-auto scrollbar-thin-dull rounded-lg"
class = "nc-col-option-select-option"
: class = " {
: class = " {
'border-1 border-gray-200' : renderedOptions . length ,
'overflow-x-auto scrollbar-thin-dull rounded-lg' : ! isKanbanStack ,
'border-1 border-gray-200' : renderedOptions . length && ! isKanbanStack ,
} "
} "
: style = " {
: style = " {
maxHeight : props . fromTableExplorer ? 'calc(100vh - (var(--topbar-height) * 3.6) - 320px)' : 'calc(min(30vh, 250px))' ,
maxHeight : props . fromTableExplorer ? 'calc(100vh - (var(--topbar-height) * 3.6) - 320px)' : 'calc(min(30vh, 250px))' ,
} "
} "
>
>
< InfiniteLoading v-if ="isReverseLazyLoad" v-bind="$attrs" @infinite="loadListDataReverse" >
< template v-if ="isKanbanStack" >
< template # spinner >
< div v-if ="kanbanStackOption" class="flex items-center nc-select-option" >
< div class = "flex flex-row w-full justify-center mt-2" >
< div class = "flex items-center w-full" >
< GeneralLoader / >
< NcDropdown
< / div >
v - model : visible = "colorMenus[kanbanStackOption.index!]"
< / template >
: auto - close = "false"
< template # complete >
overlay - class - name = "nc-select-option-color-picker"
< span > < / span >
< / template >
< / InfiniteLoading >
< 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 hover:bg-gray-100 group" >
< div
class = "flex items-center w-full"
: data - testid = "`select-column-option-${index}`"
: class = "{ removed: element.status === 'remove' }"
>
>
< div
< div class = "flex-none h-6 w-6 flex cursor-pointer mx-1" >
v - if = "!isKanban"
< div
class = "nc-child-draggable-icon p-2 flex cursor-pointer"
class = "h-6 w-6 rounded flex items-center"
: data - testid = "`select-option-column-handle-icon-${element.title}`"
: style = " {
>
backgroundColor : kanbanStackOption . color ,
< component :is ="iconMap.dragVertical" small class = "handle" / >
color : tinycolor . isReadable ( kanbanStackOption . color || '#ccc' , '#fff' , { level : 'AA' , size : 'large' } )
? '#fff'
: tinycolor . mostReadable ( kanbanStackOption . color || '#ccc' , [ '#0b1d05' , '#fff' ] ) . toHex8String ( ) ,
} "
>
< GeneralIcon icon = "arrowDown" class = "flex-none h-4 w-4 m-auto !text-current" / >
< / div >
< / div >
< / div >
< NcDropdown v -model :visible ="colorMenus[index]" :auto-close ="false" >
< template # overlay >
< div class = "flex-none h-6 w-6 flex cursor-pointer mx-1" >
< div >
< div class = "h-6 w-6 rounded flex items-center" : style = "{ backgroundColor: element.color }" >
< LazyGeneralAdvanceColorPicker
< GeneralIcon icon = "arrowDown" class = "flex-none h-4 w-4 m-auto !text-gray-600" / >
v - model = "kanbanStackOption.color"
< / div >
: is - open = "colorMenus[kanbanStackOption.index!]"
@ input = " ( el : string ) => {
kanbanStackOption ! . color = el
optionChanged ( kanbanStackOption ! )
} "
> < / LazyGeneralAdvanceColorPicker >
< / div >
< / div >
< / template >
< / NcDropdown >
< a -input
v - model : value = "kanbanStackOption.title"
placeholder = "Enter option name..."
class = "caption !rounded-lg nc-select-col-option-select-option nc-kanban-stack-input !bg-transparent"
data - testid = "nc-kanban-stack-title-input"
@ keydown . enter . prevent . stop = "syncOptions(true, true, kanbanStackOption!)"
@ change = " ( ) => {
kanbanStackOption ! . status = undefined
optionChanged ( kanbanStackOption ! )
} "
/ >
< / div >
< template # overlay >
< div
< div >
v - if = "isNewStack"
< LazyGeneralAdvanceColorPicker
class = "ml-1 hover:!text-black-500 text-gray-500 cursor-pointer hover:bg-gray-200 py-1 px-1.5 rounded-md h-7 flex items-center"
v - model = "element.color"
@ click = "emit('saveChanges', true, false)"
: is - open = "colorMenus[index]"
>
@ input = "(el:string) => (element.color = el)"
< component :is ="iconMap.close" class = "-mt-0.25 w-4 h-4" / >
> < / LazyGeneralAdvanceColorPicker >
< / div >
< / div >
< / div >
< / template >
< / template >
< / NcDropdown >
< template v-else >
< InfiniteLoading v-if ="isReverseLazyLoad" v-bind="$attrs" @infinite="loadListDataReverse" >
< a -input
< template # spinner >
v - model : value = "element.title"
< div class = "flex flex-row w-full justify-center mt-2" >
class = "caption !rounded-lg nc-select-col-option-select-option !bg-transparent"
< GeneralLoader / >
: data - testid = "`select-column-option-input-${index}`"
: disabled = "element.status === 'remove'"
@ keydown . enter . prevent = "element.title?.trim() && addNewOption()"
@ change = "optionChanged(element)"
/ >
< / div >
< / div >
< / template >
< template # complete >
< span > < / span >
< / template >
< / InfiniteLoading >
< 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 hover:bg-gray-100 group" >
< div
class = "flex items-center w-full"
: data - testid = "`select-column-option-${index}`"
: class = "{ removed: element.status === 'remove' }"
>
< div
v - if = "!isKanban"
class = "nc-child-draggable-icon p-2 flex cursor-pointer"
: data - testid = "`select-option-column-handle-icon-${element.title}`"
>
< component :is ="iconMap.dragVertical" small class = "handle" / >
< / div >
< div
< NcDropdown v -model :visible ="colorMenus[index]" :auto-close ="false" >
v - if = "element.status !== 'remove'"
< div class = "flex-none h-6 w-6 flex cursor-pointer mx-1" >
: data - testid = "`select-column-option-remove-${index}`"
< div
class = "mx-1 hover:!text-black-500 text-gray-500 cursor-pointer hover:bg-gray-200 py-1 px-1.5 rounded-md h-7 flex items-center invisible group-hover:visible"
class = "h-6 w-6 rounded flex items-center"
@ click = "removeRenderedOption(index)"
: style = " {
>
backgroundColor : element . color ,
< component :is ="iconMap.close" class = "-mt-0.25 w-4 h-4" / >
color : tinycolor . isReadable ( element . color || '#ccc' , '#fff' , { level : 'AA' , size : 'large' } )
< / div >
? '#fff'
< div
: tinycolor . mostReadable ( element . color || '#ccc' , [ '#0b1d05' , '#fff' ] ) . toHex8String ( ) ,
v - else
} "
: data - testid = "`select-column-option-remove-undo-${index}`"
>
class = "mx-1 hover:!text-black-500 text-gray-500 cursor-pointer hover:bg-gray-200 py-1 px-1.5 rounded-md h-7 flex items-center invisible group-hover:visible"
< GeneralIcon icon = "arrowDown" class = "flex-none h-4 w-4 m-auto !text-current" / >
@ click = "undoRemoveRenderedOption(index)"
< / div >
>
< / div >
< MdiArrowULeftBottom
class = "hover:!text-black-500 text-gray-500 cursor-pointer w-4 h-4"
< template # overlay >
< div >
< LazyGeneralAdvanceColorPicker
v - model = "element.color"
: is - open = "colorMenus[index]"
@ input = "(el:string) => (element.color = el)"
> < / LazyGeneralAdvanceColorPicker >
< / div >
< / template >
< / NcDropdown >
< a -input
v - model : value = "element.title"
class = "caption !rounded-lg nc-select-col-option-select-option !bg-transparent"
: data - testid = "`select-column-option-input-${index}`"
: disabled = "element.status === 'remove'"
@ keydown . enter . prevent = "element.title?.trim() && addNewOption()"
@ change = "optionChanged(element)"
/ >
< / div >
< div
v - if = "element.status !== 'remove'"
: data - testid = "`select-column-option-remove-${index}`"
class = "mx-1 hover:!text-black-500 text-gray-500 cursor-pointer hover:bg-gray-200 py-1 px-1.5 rounded-md h-7 flex items-center invisible group-hover:visible"
@ click = "removeRenderedOption(index)"
>
< component :is ="iconMap.close" class = "-mt-0.25 w-4 h-4" / >
< / div >
< div
v - else
: data - testid = "`select-column-option-remove-undo-${index}`"
class = "mx-1 hover:!text-black-500 text-gray-500 cursor-pointer hover:bg-gray-200 py-1 px-1.5 rounded-md h-7 flex items-center invisible group-hover:visible"
@ click = "undoRemoveRenderedOption(index)"
@ click = "undoRemoveRenderedOption(index)"
/ >
>
< MdiArrowULeftBottom
class = "hover:!text-black-500 text-gray-500 cursor-pointer w-4 h-4"
@ click = "undoRemoveRenderedOption(index)"
/ >
< / div >
< / div >
< / div >
< / div >
< / template >
< / template >
< / Draggabl e>
< / Draggable >
< InfiniteLoading v-if ="!isReverseLazyLoad" v-bind="$attrs" @infinite="loadListData" >
< InfiniteLoading v-if ="!isReverseLazyLoad" v-bind="$attrs" @infinite="loadListData" >
< template # spinner >
< template # spinner >
< div class = "flex flex-row w-full justify-center mt-2" >
< div class = "flex flex-row w-full justify-center mt-2" >
< GeneralLoader / >
< GeneralLoader / >
< / div >
< / div >
< / template >
< / template >
< template # complete >
< template # complete >
< span > < / span >
< span > < / span >
< / template >
< / template >
< / InfiniteLoading >
< / InfiniteLoading >
< / template >
< / div >
< / div >
< div v-if ="validateInfos?.colOptions?.help?.[0]?.[0]" class="text-error text-[10px] mb-1 mt-2" >
< div
v - if = "validateInfos?.colOptions?.help?.[0]?.[0]"
class = "text-error text-[10px] mb-1 mt-2"
: class = " {
'pl-1' : isKanbanStack ,
} "
>
{ { validateInfos . colOptions . help [ 0 ] [ 0 ] } }
{ { validateInfos . colOptions . help [ 0 ] [ 0 ] } }
< / div >
< / div >
< NcButton
< NcButton
v - if = "!isKanbanStack"
type = "secondary"
type = "secondary"
class = "w-full caption"
class = "w-full caption"
: class = " {
: class = " {
@ -488,11 +622,11 @@ const loadListData = async ($state: any) => {
: deep ( . nc - select - col - option - select - option ) {
: deep ( . nc - select - col - option - select - option ) {
@ apply ! truncate ;
@ apply ! truncate ;
& : not ( : focus ) : hover {
& : not ( . nc - kanban - stack - input ) : not ( : focus ) : hover {
@ apply ! border - transparent ;
@ apply ! border - transparent ;
}
}
& : not ( : focus ) {
& : not ( . nc - kanban - stack - input ) : not ( : focus ) {
@ apply ! border - transparent ;
@ apply ! border - transparent ;
}
}