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.
286 lines
8.5 KiB
286 lines
8.5 KiB
<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-dropdown> |
|
|
|
<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-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>
|
|
|