mirror of https://github.com/nocodb/nocodb
mertmit
12 months ago
14 changed files with 801 additions and 10 deletions
@ -0,0 +1,419 @@
|
||||
<script lang="ts" setup> |
||||
import { onUnmounted } from '@vue/runtime-core' |
||||
import tinycolor from 'tinycolor2' |
||||
import type { Select as AntSelect } from 'ant-design-vue' |
||||
import { |
||||
ActiveCellInj, |
||||
CellClickHookInj, |
||||
ColumnInj, |
||||
EditColumnInj, |
||||
EditModeInj, |
||||
IsKanbanInj, |
||||
ReadonlyInj, |
||||
RowHeightInj, |
||||
computed, |
||||
h, |
||||
inject, |
||||
isDrawerOrModalExist, |
||||
onMounted, |
||||
ref, |
||||
useEventListener, |
||||
useRoles, |
||||
useSelectedCellKeyupListener, |
||||
watch, |
||||
} from '#imports' |
||||
import MdiCloseCircle from '~icons/mdi/close-circle' |
||||
|
||||
interface Props { |
||||
modelValue?: { id: string; email: string; display_name: string }[] | null |
||||
rowIndex?: number |
||||
location?: 'cell' | 'filter' |
||||
} |
||||
|
||||
const { modelValue } = defineProps<Props>() |
||||
|
||||
const emit = defineEmits(['update:modelValue']) |
||||
|
||||
const { isMobileMode } = useGlobal() |
||||
|
||||
const column = inject(ColumnInj)! |
||||
|
||||
const readOnly = inject(ReadonlyInj)! |
||||
|
||||
const isEditable = inject(EditModeInj, ref(false)) |
||||
|
||||
const activeCell = inject(ActiveCellInj, ref(false)) |
||||
|
||||
const workspaceStore = useWorkspace() |
||||
|
||||
const { activeWorkspace } = storeToRefs(workspaceStore) |
||||
|
||||
// use both ActiveCellInj or EditModeInj to determine the active state |
||||
// since active will be false in case of form view |
||||
const active = computed(() => activeCell.value || isEditable.value) |
||||
|
||||
const isForm = inject(IsFormInj, ref(false)) |
||||
|
||||
const isEditColumn = inject(EditColumnInj, ref(false)) |
||||
|
||||
const isMultiple = computed(() => (column.value.meta as { is_multi: boolean; notify: boolean })?.is_multi) |
||||
|
||||
const rowHeight = inject(RowHeightInj, ref(undefined)) |
||||
|
||||
const aselect = ref<typeof AntSelect>() |
||||
|
||||
const isOpen = ref(false) |
||||
|
||||
const isKanban = inject(IsKanbanInj, ref(false)) |
||||
|
||||
const searchVal = ref<string | null>() |
||||
|
||||
const { isUIAllowed } = useRoles() |
||||
|
||||
const options = computed<{ id: string; email: string; display_name: string }[]>(() => { |
||||
const collaborators: { id: string; email: string; display_name: string }[] = [] |
||||
|
||||
collaborators.push( |
||||
...(activeWorkspace.value.collaborators?.map((user: any) => ({ |
||||
id: user.id, |
||||
email: user.email, |
||||
display_name: user.display_name, |
||||
})) || []), |
||||
) |
||||
|
||||
collaborators.push( |
||||
...(modelValue |
||||
?.filter((user) => { |
||||
const userExists = collaborators.find((u) => u.id === user.id) |
||||
return !userExists |
||||
}) |
||||
.map((user) => ({ |
||||
id: user.id, |
||||
email: user.email, |
||||
display_name: user.display_name, |
||||
})) || []), |
||||
) |
||||
|
||||
return collaborators |
||||
}) |
||||
|
||||
const hasEditRoles = computed(() => isUIAllowed('dataEdit')) |
||||
|
||||
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && active.value) |
||||
|
||||
const vModel = computed({ |
||||
get: () => { |
||||
const selected = modelValue?.reduce((acc, item) => { |
||||
const label = item?.display_name || item?.email |
||||
if (label) { |
||||
acc.push({ |
||||
label, |
||||
value: item.id, |
||||
}) |
||||
} |
||||
return acc |
||||
}, [] as { label: string; value: string }[]) |
||||
|
||||
return selected |
||||
}, |
||||
set: (val) => { |
||||
const value: { id: string; email: string; display_name: string }[] = [] |
||||
if (val && val.length) { |
||||
val.forEach((item) => { |
||||
// @ts-expect-error antd select returns string[] instead of { label: string, value: string }[] |
||||
const user = options.value.find((u) => u.id === item) |
||||
if (user) { |
||||
value.push(user) |
||||
} |
||||
}) |
||||
} |
||||
if (isMultiple.value) { |
||||
emit('update:modelValue', val?.length ? value : null) |
||||
} else { |
||||
emit('update:modelValue', val?.length ? value[value.length - 1] : null) |
||||
isOpen.value = false |
||||
} |
||||
}, |
||||
}) |
||||
|
||||
watch(isOpen, (n, _o) => { |
||||
if (!n) searchVal.value = '' |
||||
|
||||
if (editAllowed.value) { |
||||
if (!n) { |
||||
aselect.value?.$el?.querySelector('input')?.blur() |
||||
} else { |
||||
aselect.value?.$el?.querySelector('input')?.focus() |
||||
} |
||||
} |
||||
}) |
||||
|
||||
useSelectedCellKeyupListener(activeCell, (e) => { |
||||
switch (e.key) { |
||||
case 'Escape': |
||||
isOpen.value = false |
||||
break |
||||
case 'Enter': |
||||
if (editAllowed.value && active.value && !isOpen.value) { |
||||
isOpen.value = true |
||||
} |
||||
break |
||||
// skip space bar key press since it's used for expand row |
||||
case ' ': |
||||
break |
||||
case 'ArrowUp': |
||||
case 'ArrowDown': |
||||
case 'ArrowRight': |
||||
case 'ArrowLeft': |
||||
case 'Delete': |
||||
// skip |
||||
break |
||||
default: |
||||
if (!editAllowed.value) { |
||||
e.preventDefault() |
||||
break |
||||
} |
||||
// toggle only if char key pressed |
||||
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1 && !isDrawerOrModalExist()) { |
||||
e.stopPropagation() |
||||
isOpen.value = true |
||||
} |
||||
break |
||||
} |
||||
}) |
||||
|
||||
// close dropdown list on escape |
||||
useSelectedCellKeyupListener(isOpen, (e) => { |
||||
if (e.key === 'Escape') isOpen.value = false |
||||
}) |
||||
|
||||
const search = () => { |
||||
searchVal.value = aselect.value?.$el?.querySelector('.ant-select-selection-search-input')?.value |
||||
} |
||||
|
||||
const onTagClick = (e: Event, onClose: Function) => { |
||||
// check clicked element is remove icon |
||||
if ( |
||||
(e.target as HTMLElement)?.classList.contains('ant-tag-close-icon') || |
||||
(e.target as HTMLElement)?.closest('.ant-tag-close-icon') |
||||
) { |
||||
e.stopPropagation() |
||||
onClose() |
||||
} |
||||
} |
||||
|
||||
const cellClickHook = inject(CellClickHookInj, null) |
||||
|
||||
const toggleMenu = () => { |
||||
if (cellClickHook) return |
||||
isOpen.value = editAllowed.value && !isOpen.value |
||||
} |
||||
|
||||
const cellClickHookHandler = () => { |
||||
isOpen.value = editAllowed.value && !isOpen.value |
||||
} |
||||
onMounted(() => { |
||||
cellClickHook?.on(cellClickHookHandler) |
||||
}) |
||||
onUnmounted(() => { |
||||
cellClickHook?.on(cellClickHookHandler) |
||||
}) |
||||
|
||||
const handleClose = (e: MouseEvent) => { |
||||
// close dropdown if clicked outside of dropdown |
||||
if ( |
||||
isOpen.value && |
||||
aselect.value && |
||||
!aselect.value.$el.contains(e.target) && |
||||
!document.querySelector('.nc-dropdown-multi-select-cell.active')?.contains(e.target as Node) |
||||
) { |
||||
// loose focus when clicked outside |
||||
isEditable.value = false |
||||
isOpen.value = false |
||||
} |
||||
} |
||||
|
||||
useEventListener(document, 'click', handleClose, true) |
||||
|
||||
// search with email |
||||
const filterOption = (input: string, option: any) => { |
||||
const email = options.value.find((o) => o.id === option.value)?.email |
||||
if (email) { |
||||
return email.toLowerCase().includes(input.toLowerCase()) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="nc-multi-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu"> |
||||
<div |
||||
v-if="!active" |
||||
class="flex flex-wrap" |
||||
:style="{ |
||||
'display': '-webkit-box', |
||||
'max-width': '100%', |
||||
'-webkit-line-clamp': rowHeight || 1, |
||||
'-webkit-box-orient': 'vertical', |
||||
'overflow': 'hidden', |
||||
}" |
||||
> |
||||
<template v-for="selectedOpt of vModel" :key="selectedOpt.value"> |
||||
<a-tag class="rounded-tag" color="'#ccc'"> |
||||
<span |
||||
:style="{ |
||||
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' }) |
||||
? '#fff' |
||||
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(), |
||||
'font-size': '13px', |
||||
}" |
||||
:class="{ 'text-sm': isKanban }" |
||||
> |
||||
{{ selectedOpt.label }} |
||||
</span> |
||||
</a-tag> |
||||
</template> |
||||
</div> |
||||
|
||||
<a-select |
||||
v-else |
||||
ref="aselect" |
||||
v-model:value="vModel" |
||||
mode="multiple" |
||||
class="w-full overflow-hidden" |
||||
:placeholder="isEditColumn ? $t('labels.optional') : ''" |
||||
:bordered="false" |
||||
clear-icon |
||||
:show-search="!isMobileMode" |
||||
:show-arrow="editAllowed && !readOnly" |
||||
:open="isOpen && editAllowed" |
||||
:disabled="readOnly || !editAllowed" |
||||
:class="{ 'caret-transparent': !hasEditRoles }" |
||||
:dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`" |
||||
:filter-option="filterOption" |
||||
@search="search" |
||||
@keydown.stop |
||||
> |
||||
<template #suffixIcon> |
||||
<GeneralIcon icon="arrowDown" class="text-gray-700 nc-select-expand-btn" /> |
||||
</template> |
||||
<a-select-option |
||||
v-for="op of options" |
||||
:key="op.id || op.email" |
||||
:value="op.id" |
||||
:data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`" |
||||
:class="`nc-select-option-${column.title}-${op.email}`" |
||||
@click.stop |
||||
> |
||||
<a-tag class="rounded-tag" color="'#ccc'"> |
||||
<span |
||||
:style="{ |
||||
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' }) |
||||
? '#fff' |
||||
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(), |
||||
'font-size': '13px', |
||||
}" |
||||
:class="{ 'text-sm': isKanban }" |
||||
> |
||||
{{ op.display_name?.length ? op.display_name : op.email }} |
||||
</span> |
||||
</a-tag> |
||||
</a-select-option> |
||||
|
||||
<template #tagRender="{ label, value: val, onClose }"> |
||||
<a-tag |
||||
v-if="options.find((el) => el.id === val)" |
||||
class="rounded-tag nc-selected-option" |
||||
:style="{ display: 'flex', alignItems: 'center' }" |
||||
color="'#ccc'" |
||||
:closable="editAllowed && ((vModel?.length ?? 0) > 1 || !column?.rqd)" |
||||
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })" |
||||
@click="onTagClick($event, onClose)" |
||||
@close="onClose" |
||||
> |
||||
<span |
||||
:style="{ |
||||
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { |
||||
level: 'AA', |
||||
size: 'large', |
||||
}) |
||||
? '#fff' |
||||
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(), |
||||
'font-size': '13px', |
||||
}" |
||||
:class="{ 'text-sm': isKanban }" |
||||
> |
||||
{{ label }} |
||||
</span> |
||||
</a-tag> |
||||
</template> |
||||
</a-select> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"> |
||||
.ms-close-icon { |
||||
color: rgba(0, 0, 0, 0.25); |
||||
cursor: pointer; |
||||
display: flex; |
||||
font-size: 12px; |
||||
font-style: normal; |
||||
height: 12px; |
||||
line-height: 1; |
||||
text-align: center; |
||||
text-transform: none; |
||||
transition: color 0.3s ease, opacity 0.15s ease; |
||||
width: 12px; |
||||
z-index: 1; |
||||
margin-right: -6px; |
||||
margin-left: 3px; |
||||
} |
||||
|
||||
.ms-close-icon:before { |
||||
display: block; |
||||
} |
||||
|
||||
.ms-close-icon:hover { |
||||
color: rgba(0, 0, 0, 0.45); |
||||
} |
||||
|
||||
.read-only { |
||||
.ms-close-icon { |
||||
display: none; |
||||
} |
||||
} |
||||
|
||||
.rounded-tag { |
||||
@apply py-0 px-[12px] rounded-[12px]; |
||||
} |
||||
|
||||
:deep(.ant-tag) { |
||||
@apply "rounded-tag" my-[2px]; |
||||
} |
||||
|
||||
:deep(.ant-tag-close-icon) { |
||||
@apply "text-slate-500"; |
||||
} |
||||
|
||||
:deep(.ant-select-selection-overflow-item) { |
||||
@apply "flex overflow-hidden"; |
||||
} |
||||
|
||||
:deep(.ant-select-selection-overflow) { |
||||
@apply flex-nowrap overflow-hidden; |
||||
} |
||||
|
||||
.nc-multi-select:not(.read-only) { |
||||
:deep(.ant-select-selector), |
||||
:deep(.ant-select-selector input) { |
||||
@apply "!cursor-pointer"; |
||||
} |
||||
} |
||||
|
||||
:deep(.ant-select-selector) { |
||||
@apply !px-0; |
||||
} |
||||
|
||||
:deep(.ant-select-selection-search-input) { |
||||
@apply !text-xs; |
||||
} |
||||
</style> |
@ -0,0 +1,53 @@
|
||||
<script setup lang="ts"> |
||||
import { useVModel } from '#imports' |
||||
|
||||
const props = defineProps<{ |
||||
value: any |
||||
isEdit: boolean |
||||
}>() |
||||
|
||||
const emit = defineEmits(['update:value']) |
||||
|
||||
const vModel = useVModel(props, 'value', emit) |
||||
|
||||
const future = ref(false) |
||||
|
||||
const initialIsMulti = ref() |
||||
|
||||
const validators = {} |
||||
|
||||
const { setAdditionalValidations } = useColumnCreateStoreOrThrow() |
||||
|
||||
setAdditionalValidations({ |
||||
...validators, |
||||
}) |
||||
|
||||
// set default value |
||||
vModel.value.meta = { |
||||
is_multi: false, |
||||
notify: false, |
||||
...vModel.value.meta, |
||||
} |
||||
|
||||
onMounted(() => { |
||||
initialIsMulti.value = vModel.value.meta.is_multi |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="flex flex-col"> |
||||
<div> |
||||
<a-checkbox v-if="vModel.meta" v-model:checked="vModel.meta.is_multi" class="ml-1 mb-1"> |
||||
<span class="text-[10px] text-gray-600">Allow adding multiple users</span> |
||||
</a-checkbox> |
||||
</div> |
||||
<div v-if="future"> |
||||
<a-checkbox v-if="vModel.meta" v-model:checked="vModel.meta.notify" class="ml-1 mb-1"> |
||||
<span class="text-[10px] text-gray-600">Notify users with base access when they're added</span> |
||||
</a-checkbox> |
||||
</div> |
||||
<div v-if="initialIsMulti && isEdit && !vModel.meta.is_multi" class="text-error text-[10px] mb-1 mt-2"> |
||||
<span>Changing from multiple mode to single will retain only first user in each cell!!!</span> |
||||
</div> |
||||
</div> |
||||
</template> |
Loading…
Reference in new issue