mirror of https://github.com/nocodb/nocodb
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.
468 lines
14 KiB
468 lines
14 KiB
<script setup lang="ts"> |
|
import Draggable from 'vuedraggable' |
|
import { UITypes } from 'nocodb-sdk' |
|
import InfiniteLoading from 'v3-infinite-loading' |
|
|
|
import { IsKanbanInj, enumColor, iconMap, onMounted, useColumnCreateStoreOrThrow, useVModel } from '#imports' |
|
|
|
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" |
|
: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"> |
|
<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 rounded-md overflow-hidden border-1 border-gray-200 " |
|
> |
|
<template #overlay> |
|
<LazyGeneralColorPicker |
|
v-model="element.color" |
|
:pick-button="true" |
|
@close-modal="colorMenus[index] = false" |
|
@input="(el:string) => (element.color = el)" |
|
/> |
|
</template> |
|
<MdiArrowDownDropCircle |
|
class="mr-2 text-[1.5em] outline-0 hover:!text-[1.75em] cursor-pointer" |
|
:class="{ 'text-[1.75em]': colorMenus[index] }" |
|
:style="{ color: element.color }" |
|
/> |
|
</a-dropdown> |
|
|
|
<a-input |
|
v-model:value="element.title" |
|
class="caption !rounded-lg nc-select-col-option-select-option" |
|
: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="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> |
|
<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> |
|
<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-button> |
|
<!-- <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; |
|
} |
|
</style>
|
|
|