多维表格
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

498 lines
15 KiB

<script setup lang="ts">
import Draggable from 'vuedraggable'
import { UITypes } from 'nocodb-sdk'
import InfiniteLoading from 'v3-infinite-loading'
interface Option {
color: string
title: string
id?: string
fk_colum_id?: string
order?: number
status?: 'remove'
index?: number
}
const props = defineProps<{
value: any
fromTableExplorer?: boolean
}>()
const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit)
const { setAdditionalValidations, validateInfos } = useColumnCreateStoreOrThrow()
// const { base } = storeToRefs(useBase())
const { optionsMagic: _optionsMagic } = useNocoEe()
const optionsWrapperDomRef = ref<HTMLElement>()
const options = ref<Option[]>([])
const isAddingOption = ref(false)
// TODO: Implement proper top and bottom virtual scrolling
const OPTIONS_PAGE_COUNT = 20
const loadedOptionAnchor = ref(OPTIONS_PAGE_COUNT)
const isReverseLazyLoad = ref(false)
const renderedOptions = ref<Option[]>([])
const savedDefaultOption = ref<Option[]>([])
const colorMenus = ref<any>({})
const colors = ref(enumColor.light)
const defaultOption = ref<Option[]>([])
const isKanban = inject(IsKanbanInj, ref(false))
const { t } = useI18n()
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(t('msg.selectOption.cantBeNull')))
}
if (vModel.value.uidt === UITypes.MultiSelect && opt.title.includes(',')) {
return reject(new Error(t('msg.selectOption.multiSelectCantHaveCommas')))
}
if (options.value.filter((el) => el.title === opt.title && (el as any).status !== 'remove').length > 1) {
return reject(new Error(t('msg.selectOption.cantHaveDuplicates')))
}
}
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: [],
}
}
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)
// 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 && 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
}
})
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 = () => {
isAddingOption.value = true
const tempOption = {
title: '',
color: getNextColor(),
index: options.value.length,
}
options.value.push(tempOption)
isReverseLazyLoad.value = true
loadedOptionAnchor.value = options.value.length - OPTIONS_PAGE_COUNT
loadedOptionAnchor.value = Math.max(loadedOptionAnchor.value, 0)
renderedOptions.value = options.value.slice(loadedOptionAnchor.value, options.value.length)
optionsWrapperDomRef.value!.scrollTop = optionsWrapperDomRef.value!.scrollHeight
nextTick(() => {
// Last child doesnt work for query selector
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)
optionsWrapperDomRef.value!.scrollTop = optionsWrapperDomRef.value!.scrollHeight
isAddingOption.value = false
})
}
// const optionsMagic = async () => {
// await _optionsMagic(base, formState, getNextColor, options.value, renderedOptions.value)
// }
const syncOptions = () => {
// set initial colOptions if not set
vModel.value.colOptions = vModel.value.colOptions || {}
vModel.value.colOptions.options = options.value
.filter((op) => op.status !== 'remove')
.sort((a, b) => {
const renderA = renderedOptions.value.findIndex((el) => a.index !== undefined && el.index === a.index)
const renderB = renderedOptions.value.findIndex((el) => a.index !== undefined && el.index === b.index)
if (renderA === -1 || renderB === -1) return 0
return renderA - renderB
})
.map((op) => {
const { index: _i, status: _s, ...rest } = op
return rest
})
}
const removeRenderedOption = (index: number) => {
const renderedOption = renderedOptions.value[index]
if (renderedOption.index === undefined || isNaN(renderedOption.index)) return
const option = options.value[renderedOption.index]
renderedOption.status = 'remove'
option.status = 'remove'
syncOptions()
const optionId = renderedOptions.value[index]?.id
const removedDefaultOption = defaultOption.value.find((o) => o.id === optionId)
if (removedDefaultOption) {
if (vModel.value.uidt === UITypes.SingleSelect) {
savedDefaultOption.value = [removedDefaultOption]
defaultOption.value = []
vModel.value.cdf = null
} else {
savedDefaultOption.value = [...savedDefaultOption.value, removedDefaultOption]
defaultOption.value = defaultOption.value.filter((o) => o.id !== optionId)
vModel.value.cdf = defaultOption.value.map((o) => o.title).join(',')
}
}
}
const optionChanged = (changedElement: Option) => {
const changedDefaultOptionIndex = defaultOption.value.findIndex((o) => o.id === changedElement.id)
if (changedDefaultOptionIndex !== -1) {
if (vModel.value.uidt === UITypes.SingleSelect) {
defaultOption.value[changedDefaultOptionIndex].title = changedElement.title
vModel.value.cdf = changedElement.title
} else {
defaultOption.value[changedDefaultOptionIndex].title = changedElement.title
vModel.value.cdf = defaultOption.value.map((o) => o.title).join(',')
}
}
syncOptions()
}
const undoRemoveRenderedOption = (index: number) => {
const renderedOption = renderedOptions.value[index]
if (renderedOption.index === undefined || isNaN(renderedOption.index)) return
const option = options.value[renderedOption.index]
renderedOption.status = undefined
option.status = undefined
syncOptions()
const optionId = renderedOptions.value[index]?.id
const addedDefaultOption = savedDefaultOption.value.find((o) => o.id === optionId)
if (addedDefaultOption) {
if (vModel.value.uidt === UITypes.SingleSelect) {
defaultOption.value = [addedDefaultOption]
vModel.value.cdf = addedDefaultOption.title
savedDefaultOption.value = []
} else {
defaultOption.value = [...defaultOption.value, addedDefaultOption]
vModel.value.cdf = defaultOption.value.map((o) => o.title).join(',')
savedDefaultOption.value = savedDefaultOption.value.filter((o) => o.id !== optionId)
}
}
}
// focus last created input
// watch(inputs, () => {
// if (inputs.value?.$el) {
// inputs.value.$el.focus()
// }
// })
// Removes the Select Option from cdf if the option is removed
watch(vModel, (next) => {
const cdfs = (next.cdf ?? '').toString().split(',')
const valuesMap = (next.colOptions?.options ?? []).reduce((acc, c) => {
acc[c.title.replace(/^'|'$/g, '')] = c
return acc
}, {})
defaultOption.value = []
const newCdf = cdfs
.filter((c: string) => {
if (valuesMap[c]) {
defaultOption.value.push(valuesMap[c])
return true
}
return false
})
.join(',')
next.cdf = newCdf.length === 0 ? null : newCdf
})
const loadListDataReverse = async ($state: any) => {
if (isAddingOption.value) return
if (loadedOptionAnchor.value === 0) {
$state.complete()
return
}
$state.loading()
loadedOptionAnchor.value -= OPTIONS_PAGE_COUNT
loadedOptionAnchor.value = Math.max(loadedOptionAnchor.value, 0)
renderedOptions.value = options.value.slice(loadedOptionAnchor.value, options.value.length)
optionsWrapperDomRef.value!.scrollTop = optionsWrapperDomRef.value!.scrollTop + 100
if (loadedOptionAnchor.value === 0) {
$state.complete()
return
}
$state.loaded()
}
const loadListData = async ($state: any) => {
if (isAddingOption.value) return
if (loadedOptionAnchor.value === options.value.length) {
return $state.complete()
}
$state.loading()
loadedOptionAnchor.value += OPTIONS_PAGE_COUNT
loadedOptionAnchor.value = Math.min(loadedOptionAnchor.value, options.value.length)
renderedOptions.value = options.value.slice(0, loadedOptionAnchor.value)
if (loadedOptionAnchor.value === options.value.length) {
return $state.complete()
}
$state.loaded()
}
</script>
<template>
<div class="w-full">
<div
ref="optionsWrapperDomRef"
class="nc-col-option-select-option overflow-x-auto scrollbar-thin-dull rounded-lg"
:class="{
'border-1 border-gray-200': renderedOptions.length,
}"
:style="{
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 #spinner>
<div class="flex flex-row w-full justify-center mt-2">
<GeneralLoader />
</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>
<NcDropdown v-model:visible="colorMenus[index]" :auto-close="false">
<div class="flex-none h-6 w-6 flex cursor-pointer mx-1">
<div class="h-6 w-6 rounded flex items-center" :style="{ backgroundColor: element.color }">
<GeneralIcon icon="arrowDown" class="flex-none h-4 w-4 m-auto !text-gray-600" />
</div>
</div>
<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)"
>
<MdiArrowULeftBottom
class="hover:!text-black-500 text-gray-500 cursor-pointer w-4 h-4"
@click="undoRemoveRenderedOption(index)"
/>
</div>
</div>
</template>
</Draggable>
<InfiniteLoading v-if="!isReverseLazyLoad" v-bind="$attrs" @infinite="loadListData">
<template #spinner>
<div class="flex flex-row w-full justify-center mt-2">
<GeneralLoader />
</div>
</template>
<template #complete>
<span></span>
</template>
</InfiniteLoading>
</div>
<div v-if="validateInfos?.colOptions?.help?.[0]?.[0]" class="text-error text-[10px] mb-1 mt-2">
{{ validateInfos.colOptions.help[0][0] }}
</div>
<NcButton
type="secondary"
class="w-full caption"
:class="{
'mt-2': renderedOptions.length,
}"
size="small"
data-testid="nc-add-select-option-btn"
@click="addNewOption()"
>
<div class="flex items-center">
<component :is="iconMap.plus" />
<span class="flex-auto">Add option</span>
</div>
</NcButton>
<!-- <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: 1px;
background: #ccc;
content: '';
width: calc(100% + 5px);
display: block;
}
:deep(.nc-select-col-option-select-option) {
@apply !truncate;
&:not(:focus):hover {
@apply !border-transparent;
}
&:not(:focus) {
@apply !border-transparent;
}
&:focus,
&:focus-visible {
@apply !border-[var(--ant-primary-color-hover)];
}
}
</style>