Browse Source

Merge pull request #4957 from nocodb/fix/single-select-issues

fix: Grid view - Single-select/Multi-select related bugs and improvements
pull/4999/head
Raju Udava 2 years ago committed by GitHub
parent
commit
eed498a154
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      packages/nc-gui/components/cell/Checkbox.vue
  2. 237
      packages/nc-gui/components/cell/MultiSelect.vue
  3. 169
      packages/nc-gui/components/cell/SingleSelect.vue
  4. 2
      packages/nc-gui/components/smartsheet/Grid.vue
  5. 8
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  6. 2
      packages/nc-gui/composables/useProject.ts
  7. 1
      packages/nc-gui/context/index.ts
  8. 10
      tests/playwright/tests/columnMultiSelect.spec.ts

4
packages/nc-gui/components/cell/Checkbox.vue

@ -63,7 +63,7 @@ useSelectedCellKeyupListener(active, (e) => {
<template>
<div
class="flex cursor-pointer"
class="flex cursor-pointer w-full h-full"
:class="{
'justify-center': !isForm,
'w-full': isForm,
@ -72,7 +72,7 @@ useSelectedCellKeyupListener(active, (e) => {
}"
@click="onClick(false, $event)"
>
<div class="px-1 pt-1 rounded-full items-center" :class="{ 'bg-gray-100': !vModel, '!ml-[-8px]': readOnly }">
<div class="p-1 rounded-full items-center" :class="{ 'bg-gray-100': !vModel, '!ml-[-8px]': readOnly }">
<Transition name="layout" mode="out-in" :duration="100">
<component
:is="getMdiIcon(vModel ? checkboxMeta.icon.checked : checkboxMeta.icon.unchecked)"

237
packages/nc-gui/components/cell/MultiSelect.vue

@ -1,10 +1,12 @@
<script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
IsKanbanInj,
ReadonlyInj,
@ -13,6 +15,7 @@ import {
extractSdkResponseErrorMsg,
h,
inject,
isDrawerOrModalExist,
onMounted,
reactive,
ref,
@ -44,6 +47,8 @@ const editable = inject(EditModeInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const selectedIds = ref<string[]>([])
const aselect = ref<typeof AntSelect>()
@ -85,7 +90,7 @@ const isOptionMissing = computed(() => {
const hasEditRoles = computed(() => hasRole('owner', true) || hasRole('creator', true) || hasRole('editor', true))
const editAllowed = computed(() => hasEditRoles.value && (active.value || editable.value))
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && (active.value || editable.value))
const vModel = computed({
get: () => {
@ -126,12 +131,6 @@ const selectedTitles = computed(() =>
: [],
)
const handleClose = (e: MouseEvent) => {
if (aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false
}
}
onMounted(() => {
selectedIds.value = selectedTitles.value.flatMap((el) => {
const item = options.value.find((op) => op.title === el)
@ -144,8 +143,6 @@ onMounted(() => {
})
})
useEventListener(document, 'click', handleClose)
watch(
() => modelValue,
() => {
@ -161,6 +158,8 @@ watch(
)
watch(isOpen, (n, _o) => {
if (!n) searchVal.value = ''
if (editAllowed.value) {
if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
@ -180,6 +179,9 @@ useSelectedCellKeyupListener(active, (e) => {
isOpen.value = true
}
break
// skip space bar key press since it's used for expand row
case ' ':
break
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowRight':
@ -193,7 +195,7 @@ useSelectedCellKeyupListener(active, (e) => {
break
}
// toggle only if char key pressed
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1) {
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1 && !isDrawerOrModalExist()) {
e.stopPropagation()
isOpen.value = true
}
@ -201,6 +203,11 @@ useSelectedCellKeyupListener(active, (e) => {
}
})
// close dropdown list on escape
useSelectedCellKeyupListener(isOpen, (e) => {
if (e.key === 'Escape') isOpen.value = false
})
const activeOptCreateInProgress = ref(0)
async function addIfMissingAndSave() {
@ -273,95 +280,126 @@ const onTagClick = (e: Event, onClose: Function) => {
onClose()
}
}
const cellClickHook = inject(CellClickHookInj)
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)
) {
isOpen.value = false
}
}
useEventListener(document, 'click', handleClose, true)
</script>
<template>
<a-select
ref="aselect"
v-model:value="vModel"
v-model:open="isOpen"
mode="multiple"
class="w-full overflow-hidden"
:bordered="false"
clear-icon
show-search
:show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel.length === 0))"
:open="isOpen && (active || editable)"
:disabled="readOnly"
:class="{ '!ml-[-8px]': readOnly, 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`"
@search="search"
@keydown.stop
@click="isOpen = editAllowed && !isOpen"
>
<a-select-option
v-for="op of options"
:key="op.id || op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop
<div class="nc-multi-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<a-select
ref="aselect"
v-model:value="vModel"
mode="multiple"
class="w-full overflow-hidden"
:bordered="false"
clear-icon
show-search
:show-arrow="editAllowed && !readOnly"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`"
@search="search"
@keydown.stop
>
<a-tag class="rounded-tag" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.title }}
</span>
</a-tag>
</a-select-option>
<a-select-option
v-if="searchVal && isOptionMissing && !isPublic && (hasRole('owner', true) || hasRole('creator', true))"
:key="searchVal"
:value="searchVal"
>
<div class="flex gap-2 text-gray-500 items-center h-full">
<MdiPlusThick class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
</div>
</div>
</a-select-option>
<template #tagRender="{ value: val, onClose }">
<a-tag
v-if="options.find((el) => el.title === val)"
class="rounded-tag nc-selected-option"
:style="{ display: 'flex', alignItems: 'center' }"
:color="options.find((el) => el.title === val)?.color"
:closable="editAllowed && (active || editable) && (vModel.length > 1 || !column?.rqd)"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@click="onTagClick($event, onClose)"
@close="onClose"
<a-select-option
v-for="op of options"
:key="op.id || op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop
>
<span
:style="{
'color': tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor
.mostReadable(options.find((el) => el.title === val)?.color || '#ccc', ['#0b1d05', '#fff'])
.toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
<a-tag class="rounded-tag" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.title }}
</span>
</a-tag>
</a-select-option>
<a-select-option
v-if="searchVal && isOptionMissing && !isPublic && (hasRole('owner', true) || hasRole('creator', true))"
:key="searchVal"
:value="searchVal"
>
<div class="flex gap-2 text-gray-500 items-center h-full">
<MdiPlusThick class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
</div>
</div>
</a-select-option>
<template #tagRender="{ value: val, onClose }">
<a-tag
v-if="options.find((el) => el.title === val)"
class="rounded-tag nc-selected-option"
:style="{ display: 'flex', alignItems: 'center' }"
:color="options.find((el) => el.title === val)?.color"
:closable="editAllowed && (vModel.length > 1 || !column?.rqd)"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@click="onTagClick($event, onClose)"
@close="onClose"
>
{{ val }}
</span>
</a-tag>
</template>
</a-select>
<span
:style="{
'color': tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor
.mostReadable(options.find((el) => el.title === val)?.color || '#ccc', ['#0b1d05', '#fff'])
.toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ val }}
</span>
</a-tag>
</template>
</a-select>
</div>
</template>
<style scoped>
<style scoped lang="scss">
.ms-close-icon {
color: rgba(0, 0, 0, 0.25);
cursor: pointer;
@ -404,6 +442,21 @@ const onTagClick = (e: Event, onClose: Function) => {
}
:deep(.ant-select-selection-overflow) {
@apply flex-nowrap;
@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>

169
packages/nc-gui/components/cell/SingleSelect.vue

@ -1,18 +1,22 @@
<script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
EditModeInj,
IsFormInj,
IsKanbanInj,
ReadonlyInj,
computed,
enumColor,
extractSdkResponseErrorMsg,
inject,
isDrawerOrModalExist,
ref,
useRoles,
useSelectedCellKeyupListener,
@ -44,6 +48,8 @@ const isKanban = inject(IsKanbanInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const { $api } = useNuxtApp()
const searchVal = ref()
@ -78,7 +84,7 @@ const isOptionMissing = computed(() => {
const hasEditRoles = computed(() => hasRole('owner', true) || hasRole('creator', true) || hasRole('editor', true))
const editAllowed = computed(() => hasEditRoles.value && (active.value || editable.value))
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && (active.value || editable.value))
const vModel = computed({
get: () => tempSelectedOptState.value ?? modelValue,
@ -113,13 +119,16 @@ useSelectedCellKeyupListener(active, (e) => {
isOpen.value = true
}
break
// skip space bar key press since it's used for expand row
case ' ':
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) {
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1 && !isDrawerOrModalExist()) {
e.stopPropagation()
isOpen.value = true
}
@ -127,6 +136,11 @@ useSelectedCellKeyupListener(active, (e) => {
}
})
// close dropdown list on escape
useSelectedCellKeyupListener(isOpen, (e) => {
if (e.key === 'Escape') isOpen.value = false
})
async function addIfMissingAndSave() {
if (!searchVal.value || isPublic.value) return false
@ -177,6 +191,22 @@ const search = () => {
searchVal.value = aselect.value?.$el?.querySelector('.ant-select-selection-search-input')?.value
}
// prevent propagation of keydown event if select is open
const onKeydown = (e: KeyboardEvent) => {
if (isOpen.value && (active.value || editable.value)) {
e.stopPropagation()
}
if (e.key === 'Enter') {
e.stopPropagation()
}
}
const onSelect = () => {
isOpen.value = false
}
const cellClickHook = inject(CellClickHookInj)
const toggleMenu = (e: Event) => {
// todo: refactor
// check clicked element is clear icon
@ -185,64 +215,84 @@ const toggleMenu = (e: Event) => {
(e.target as HTMLElement)?.closest('.ant-select-clear')
) {
vModel.value = ''
return
return e.stopPropagation()
}
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) => {
if (isOpen.value && aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false
}
}
useEventListener(document, 'click', handleClose, true)
</script>
<template>
<a-select
ref="aselect"
v-model:value="vModel"
class="w-full"
:class="{ 'caret-transparent': !hasEditRoles }"
:allow-clear="!column.rqd && editAllowed"
:bordered="false"
:open="isOpen"
:disabled="readOnly"
:show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel === null))"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen ? 'active' : ''}`"
show-search
@select="isOpen = false"
@keydown.stop
@search="search"
@click="toggleMenu"
>
<a-select-option
v-for="op of options"
:key="op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.title }}
</span>
</a-tag>
</a-select-option>
<a-select-option
v-if="searchVal && isOptionMissing && !isPublic && (hasRole('owner', true) || hasRole('creator', true))"
:key="searchVal"
:value="searchVal"
<div class="h-full w-full flex items-center nc-single-select" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<a-select
ref="aselect"
v-model:value="vModel"
class="w-full"
:class="{ 'caret-transparent': !hasEditRoles }"
:allow-clear="!column.rqd && editAllowed"
:bordered="false"
:open="isOpen && (active || editable)"
:disabled="readOnly || !(active || editable)"
:show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel === null))"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen && (active || editable) ? 'active' : ''}`"
:show-search="isOpen && (active || editable)"
@select="onSelect"
@keydown="onKeydown($event)"
@search="search"
>
<div class="flex gap-2 text-gray-500 items-center h-full">
<MdiPlusThick class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
<a-select-option
v-for="op of options"
:key="op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.title }}
</span>
</a-tag>
</a-select-option>
<a-select-option
v-if="searchVal && isOptionMissing && !isPublic && (hasRole('owner', true) || hasRole('creator', true))"
:key="searchVal"
:value="searchVal"
>
<div class="flex gap-2 text-gray-500 items-center h-full">
<MdiPlusThick class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
</div>
</div>
</div>
</a-select-option>
</a-select>
</a-select-option>
</a-select>
</div>
</template>
<style scoped lang="scss">
@ -257,4 +307,19 @@ const toggleMenu = (e: Event) => {
:deep(.ant-select-clear) {
opacity: 1;
}
.nc-single-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>

2
packages/nc-gui/components/smartsheet/Grid.vue

@ -495,7 +495,7 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
/** On clicking outside of table reset active cell */
const smartTable = ref(null)
onClickOutside(smartTable, (e) => {
onClickOutside(tbodyEl, (e) => {
// do nothing if context menu was open
if (contextMenu.value) return

8
packages/nc-gui/components/smartsheet/TableDataCell.vue

@ -1,10 +1,14 @@
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, useSmartsheetStoreOrThrow } from '#imports'
import { CellClickHookInj, createEventHook, onBeforeUnmount, onMounted, ref, useSmartsheetStoreOrThrow } from '#imports'
const { cellRefs } = useSmartsheetStoreOrThrow()
const el = ref<HTMLTableDataCellElement>()
const cellClickHook = createEventHook()
provide(CellClickHookInj, cellClickHook)
onMounted(() => {
cellRefs.value.push(el.value!)
})
@ -18,7 +22,7 @@ onBeforeUnmount(() => {
</script>
<template>
<td ref="el">
<td ref="el" @click="cellClickHook.trigger($event)">
<slot />
</td>
</template>

2
packages/nc-gui/composables/useProject.ts

@ -1,7 +1,6 @@
import type { BaseType, OracleUi, ProjectType, TableType } from 'nocodb-sdk'
import { SqlUiFactory } from 'nocodb-sdk'
import { isString } from '@vueuse/core'
import { useRoute } from 'vue-router'
import {
ClientType,
computed,
@ -12,6 +11,7 @@ import {
useInjectionState,
useNuxtApp,
useRoles,
useRoute,
useRouter,
useTheme,
} from '#imports'

1
packages/nc-gui/context/index.ts

@ -32,3 +32,4 @@ export const SharedViewPasswordInj: InjectionKey<Ref<string | null>> = Symbol('s
export const CellUrlDisableOverlayInj: InjectionKey<Ref<boolean>> = Symbol('cell-url-disable-url')
export const DropZoneRef: InjectionKey<Ref<Element | undefined>> = Symbol('drop-zone-ref')
export const ToggleDialogInj: InjectionKey<Function> = Symbol('toggle-dialog-injection')
export const CellClickHookInj: InjectionKey<EventHook<MouseEvent> | undefined> = Symbol('cell-click-injection')

10
tests/playwright/tests/columnMultiSelect.spec.ts

@ -76,18 +76,22 @@ test.describe('Multi select', () => {
multiSelect: true,
});
await grid.column.selectOption.editOption({ index: 2, columnTitle: 'MultiSelect', newOption: 'New Option 3' });
await grid.column.selectOption.editOption({
index: 2,
columnTitle: 'MultiSelect',
newOption: 'MultiSelect New Option 3',
});
await grid.cell.selectOption.verify({
index: 0,
columnHeader: 'MultiSelect',
option: 'New Option 3',
option: 'MultiSelect New Option 3',
multiSelect: true,
});
await grid.cell.selectOption.verifyOptions({
index: 0,
columnHeader: 'MultiSelect',
options: ['Option 1', 'Option 2', 'New Option 3'],
options: ['Option 1', 'Option 2', 'MultiSelect New Option 3'],
});
await grid.deleteRow(0);

Loading…
Cancel
Save