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.
641 lines
20 KiB
641 lines
20 KiB
<script setup lang="ts"> |
|
import Draggable from 'vuedraggable' |
|
import tinycolor from 'tinycolor2' |
|
import { type SelectOptionsType, 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' | 'new' |
|
index?: number |
|
} |
|
|
|
const props = defineProps<{ |
|
value: any |
|
fromTableExplorer?: boolean |
|
isKanbanStack?: boolean |
|
optionId?: string |
|
isNewStack?: boolean |
|
}>() |
|
|
|
const emit = defineEmits(['update:value', 'saveChanges']) |
|
|
|
const vModel = useVModel(props, 'value', emit) |
|
|
|
const { isKanbanStack, optionId, isNewStack } = toRefs(props) |
|
|
|
const { setAdditionalValidations, validateInfos, column } = 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' || (opt as any).status === 'new') 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) |
|
|
|
const kanbanStackOption = computed(() => { |
|
if (isNewStack.value) { |
|
return renderedOptions.value[renderedOptions.value.length - 1] |
|
} else if (optionId.value) { |
|
return renderedOptions.value.find((o) => o.id === optionId.value) |
|
} |
|
return null |
|
}) |
|
|
|
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, |
|
...(isKanbanStack.value ? { status: 'new' } : {}), |
|
} |
|
options.value.push(tempOption as Option) |
|
|
|
if (isKanbanStack.value) { |
|
renderedOptions.value = options.value |
|
} else { |
|
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 = (saveChanges: boolean = false, submit: boolean = false, payload?: Option) => { |
|
// 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 { status: _s, ...rest } = op |
|
return rest |
|
}) |
|
|
|
if (saveChanges) { |
|
emit('saveChanges', submit, true, payload) |
|
} |
|
} |
|
|
|
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, saveChanges: boolean = false) => { |
|
const changedDefaultOptionIndex = defaultOption.value.findIndex((o) => { |
|
if (o.id !== undefined && changedElement.id !== undefined) { |
|
return o.id === changedElement.id |
|
} else { |
|
return o.index === changedElement.index |
|
} |
|
}) |
|
|
|
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(saveChanges) |
|
} |
|
|
|
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() |
|
} |
|
|
|
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) |
|
} |
|
|
|
// 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 |
|
} |
|
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> |
|
|
|
<template> |
|
<div class="w-full"> |
|
<div |
|
ref="optionsWrapperDomRef" |
|
class="nc-col-option-select-option" |
|
:class="{ |
|
'overflow-x-auto scrollbar-thin-dull rounded-lg': !isKanbanStack, |
|
'border-1 border-gray-200': renderedOptions.length && !isKanbanStack, |
|
}" |
|
:style="{ |
|
maxHeight: props.fromTableExplorer ? 'calc(100vh - (var(--topbar-height) * 3.6) - 320px)' : 'calc(min(30vh, 250px))', |
|
}" |
|
> |
|
<template v-if="isKanbanStack"> |
|
<div v-if="kanbanStackOption" class="flex items-center nc-select-option"> |
|
<div class="flex items-center w-full"> |
|
<NcDropdown |
|
v-model:visible="colorMenus[kanbanStackOption.index!]" |
|
:auto-close="false" |
|
overlay-class-name="nc-select-option-color-picker" |
|
> |
|
<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: kanbanStackOption.color, |
|
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> |
|
|
|
<template #overlay> |
|
<div> |
|
<LazyGeneralAdvanceColorPicker |
|
v-model="kanbanStackOption.color" |
|
:is-open="colorMenus[kanbanStackOption.index!]" |
|
@input="(el:string) => { |
|
kanbanStackOption!.color = el |
|
optionChanged(kanbanStackOption!) |
|
}" |
|
></LazyGeneralAdvanceColorPicker> |
|
</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> |
|
|
|
<div |
|
v-if="isNewStack" |
|
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" |
|
@click="emit('saveChanges', true, false)" |
|
> |
|
<component :is="iconMap.close" class="-mt-0.25 w-4 h-4" /> |
|
</div> |
|
</div> |
|
</template> |
|
<template v-else> |
|
<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, |
|
color: tinycolor.isReadable(element.color || '#ccc', '#fff', { level: 'AA', size: 'large' }) |
|
? '#fff' |
|
: tinycolor.mostReadable(element.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(), |
|
}" |
|
> |
|
<GeneralIcon icon="arrowDown" class="flex-none h-4 w-4 m-auto !text-current" /> |
|
</div> |
|
</div> |
|
|
|
<template #overlay> |
|
<div> |
|
<LazyGeneralAdvanceColorPicker |
|
v-model="element.color" |
|
:is-open="colorMenus[index]" |
|
@input="(el:string) => { |
|
element.color = el |
|
optionChanged(element) |
|
}" |
|
></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> |
|
</template> |
|
</div> |
|
|
|
<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] }} |
|
</div> |
|
<NcButton |
|
v-if="!isKanbanStack" |
|
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(.nc-kanban-stack-input):not(:focus):hover { |
|
@apply !border-transparent; |
|
} |
|
|
|
&:not(.nc-kanban-stack-input):not(:focus) { |
|
@apply !border-transparent; |
|
} |
|
|
|
&:focus, |
|
&:focus-visible { |
|
@apply !border-[var(--ant-primary-color-hover)]; |
|
} |
|
} |
|
</style>
|
|
|