Browse Source

Merge pull request #5896 from nocodb/feat/fill-handle

feat: vertical fill using handle
pull/5995/head
Raju Udava 1 year ago committed by GitHub
parent
commit
1aa49c7eb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/nc-gui/components/cell/DatePicker.vue
  2. 2
      packages/nc-gui/components/cell/DateTimePicker.vue
  3. 2
      packages/nc-gui/components/cell/TimePicker.vue
  4. 2
      packages/nc-gui/components/cell/YearPicker.vue
  5. 528
      packages/nc-gui/components/smartsheet/Grid.vue
  6. 2
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  7. 18
      packages/nc-gui/composables/useMultiSelect/cellRange.ts
  8. 281
      packages/nc-gui/composables/useMultiSelect/index.ts
  9. 1
      tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts
  10. 1
      tests/playwright/pages/Dashboard/common/Cell/RatingCell.ts
  11. 4
      tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts
  12. 32
      tests/playwright/pages/Dashboard/common/Cell/TimeCell.ts
  13. 258
      tests/playwright/tests/db/verticalFillHandle.spec.ts

2
packages/nc-gui/components/cell/DatePicker.vue

@ -70,6 +70,8 @@ watch(
(next) => {
if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false))
} else {
editable.value = false
}
},
{ flush: 'post' },

2
packages/nc-gui/components/cell/DateTimePicker.vue

@ -122,6 +122,8 @@ watch(
(next) => {
if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false))
} else {
editable.value = false
}
},
{ flush: 'post' },

2
packages/nc-gui/components/cell/TimePicker.vue

@ -69,6 +69,8 @@ watch(
(next) => {
if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false))
} else {
editable.value = false
}
},
{ flush: 'post' },

2
packages/nc-gui/components/cell/YearPicker.vue

@ -55,6 +55,8 @@ watch(
(next) => {
if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false))
} else {
editable.value = false
}
},
{ flush: 'post' },

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

@ -101,6 +101,8 @@ const contextMenu = computed({
})
const contextMenuClosing = ref(false)
const scrolling = ref(false)
const bulkUpdateDlg = ref(false)
const routeQuery = $computed(() => route.query as Record<string, string>)
@ -111,6 +113,9 @@ const expandedFormRowState = ref<Record<string, any>>()
const gridWrapper = ref<HTMLElement>()
const tableHeadEl = ref<HTMLElement>()
const tableBodyEl = ref<HTMLElement>()
const fillHandle = ref<HTMLElement>()
const gridRect = useElementBounding(gridWrapper)
const isAddingColumnAllowed = $computed(() => !readOnly.value && !isLocked.value && isUIAllowed('add-column') && !isSqlView.value)
@ -209,6 +214,8 @@ const {
resetSelectedRange,
makeActive,
selectedRange,
isCellInFillRange,
isFillMode,
} = useMultiSelect(
meta,
fields,
@ -350,6 +357,7 @@ const {
await updateOrSaveRow(rowObj, ctx.updatedColumnTitle || columnObj.title)
},
bulkUpdateRows,
fillHandle,
)
function scrollToCell(row?: number | null, col?: number | null) {
@ -652,10 +660,15 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
}
})
/** On clicking outside of table reset active cell */
const smartTable = ref(null)
/** On clicking outside of table reset active cell */
onClickOutside(tableBodyEl, (e) => {
// do nothing if mousedown on the scrollbar (scrolling)
if (scrolling.value) {
return
}
// do nothing if context menu was open
if (contextMenu.value) return
@ -665,6 +678,9 @@ onClickOutside(tableBodyEl, (e) => {
if (editEnabled && (isVirtualCol(activeCol) || activeCol.uidt === UITypes.JSON)) return
// skip if fill mode is active
if (isFillMode.value) return
// ignore unselecting if clicked inside or on the picker(Date, Time, DateTime, Year)
// or single/multi select options
const activePickerOrDropdownEl = document.querySelector(
@ -865,6 +881,15 @@ const closeAddColumnDropdown = (scrollToLastCol = false) => {
const confirmDeleteRow = (row: number) => {
try {
deleteRow(row)
if (selectedRange.isRowInRange(row)) {
clearSelectedRange()
}
if (activeCell.row === row) {
activeCell.row = null
activeCell.col = null
}
} catch (e: any) {
message.error(e.message)
}
@ -883,10 +908,65 @@ function addEmptyRow(row?: number) {
nextTick().then(() => {
clearSelectedRange()
makeActive(row ?? data.value.length - 1, 0)
selectedRange.startRange({ row: activeCell.row!, col: activeCell.col! })
scrollToCell?.()
})
return rowObj
}
const fillHandleTop = ref()
const fillHandleLeft = ref()
const cellRefs = ref<{ el: HTMLElement }[]>([])
const showFillHandle = computed(
() =>
!readOnly.value &&
!isLocked.value &&
!editEnabled &&
(!selectedRange.isEmpty() || (activeCell.row !== null && activeCell.col !== null)) &&
!data.value[(isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) ?? -1]?.rowMeta?.new,
)
const refreshFillHandle = () => {
const cellRef = cellRefs.value.find(
(cell) =>
cell.el.dataset.rowIndex === String(isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) &&
cell.el.dataset.colIndex === String(isNaN(selectedRange.end.col) ? activeCell.col : selectedRange.end.col),
)
if (cellRef) {
const cellRect = useElementBounding(cellRef.el)
if (!cellRect || !gridWrapper.value) return
fillHandleTop.value = cellRect.top.value + cellRect.height.value - gridRect.top.value + gridWrapper.value.scrollTop
fillHandleLeft.value = cellRect.left.value + cellRect.width.value - gridRect.left.value + gridWrapper.value.scrollLeft
}
}
watch(
[() => selectedRange.end.row, () => selectedRange.end.col, () => activeCell.row, () => activeCell.col],
([sr, sc, ar, ac], [osr, osc, oar, oac]) => {
if (sr !== osr || sc !== osc || ar !== oar || ac !== oac) {
refreshFillHandle()
}
},
)
useEventListener(gridWrapper, 'scroll', () => {
refreshFillHandle()
})
useEventListener(document, 'mousedown', (e) => {
if (e.offsetX > (e.target as HTMLElement)?.clientWidth || e.offsetY > (e.target as HTMLElement)?.clientHeight) {
scrolling.value = true
}
})
useEventListener(document, 'mouseup', () => {
// wait for click event to finish before setting scrolling to false
setTimeout(() => {
scrolling.value = false
}, 100)
})
</script>
<template>
@ -897,219 +977,237 @@ function addEmptyRow(row?: number) {
</div>
</general-overlay>
<div ref="gridWrapper" class="nc-grid-wrapper min-h-0 flex-1 scrollbar-thin-dull">
<div ref="gridWrapper" class="nc-grid-wrapper min-h-0 flex-1 scrollbar-thin-dull relative">
<a-dropdown
v-model:visible="contextMenu"
:trigger="isSqlView ? [] : ['contextmenu']"
overlay-class-name="nc-dropdown-grid-context-menu"
>
<table
ref="smartTable"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white"
@contextmenu="showContextMenu"
>
<thead ref="tableHeadEl">
<tr class="nc-grid-header">
<th class="w-[85px] min-w-[85px]" data-testid="grid-id-column">
<div class="w-full h-full bg-gray-100 flex pl-5 pr-1 items-center" data-testid="nc-check-all">
<template v-if="!readOnly">
<div class="nc-no-label text-gray-500" :class="{ hidden: selectedAllRecords }">#</div>
<div
:class="{ hidden: !selectedAllRecords, flex: selectedAllRecords }"
class="nc-check-all w-full items-center"
>
<a-checkbox v-model:checked="selectedAllRecords" />
<div class="table-overlay">
<table
ref="smartTable"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white"
@contextmenu="showContextMenu"
>
<thead ref="tableHeadEl">
<tr class="nc-grid-header">
<th class="w-[85px] min-w-[85px]" data-testid="grid-id-column">
<div class="w-full h-full bg-gray-100 flex pl-5 pr-1 items-center" data-testid="nc-check-all">
<template v-if="!readOnly">
<div class="nc-no-label text-gray-500" :class="{ hidden: selectedAllRecords }">#</div>
<div
:class="{ hidden: !selectedAllRecords, flex: selectedAllRecords }"
class="nc-check-all w-full items-center"
>
<a-checkbox v-model:checked="selectedAllRecords" />
<span class="flex-1" />
</div>
</template>
<template v-else>
<div class="text-gray-500">#</div>
</template>
</div>
</th>
<th
v-for="col in fields"
:key="col.title"
v-xc-ver-resize
:data-col="col.id"
:data-title="col.title"
@xcresize="onresize(col.id, $event)"
@xcresizing="onXcResizing(col.title, $event)"
@xcresized="resizingCol = null"
>
<div class="w-full h-full bg-gray-100 flex items-center">
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" :hide-menu="readOnly" />
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="readOnly" />
</div>
</th>
<th
v-if="isAddingColumnAllowed"
v-e="['c:column:add']"
class="cursor-pointer"
@click.stop="addColumnDropdown = true"
>
<a-dropdown
v-model:visible="addColumnDropdown"
:trigger="['click']"
overlay-class-name="nc-dropdown-grid-add-column"
>
<div class="h-full w-[60px] flex items-center justify-center">
<component :is="iconMap.plus" class="text-sm nc-column-add" />
<span class="flex-1" />
</div>
</template>
<template v-else>
<div class="text-gray-500">#</div>
</template>
</div>
</th>
<th
v-for="col in fields"
:key="col.title"
v-xc-ver-resize
:data-col="col.id"
:data-title="col.title"
@xcresize="onresize(col.id, $event)"
@xcresizing="onXcResizing(col.title, $event)"
@xcresized="resizingCol = null"
>
<div class="w-full h-full bg-gray-100 flex items-center">
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" :hide-menu="readOnly" />
<template #overlay>
<SmartsheetColumnEditOrAddProvider
v-if="addColumnDropdown"
:preload="preloadColumn"
:column-position="columnOrder"
@submit="closeAddColumnDropdown(true)"
@cancel="closeAddColumnDropdown()"
@click.stop
@keydown.stop
/>
</template>
</a-dropdown>
</th>
</tr>
</thead>
<tbody ref="tableBodyEl">
<LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row">
<template #default="{ state }">
<tr
class="nc-grid-row"
:style="{ height: rowHeight ? `${rowHeight * 1.8}rem` : `1.8rem` }"
:data-testid="`grid-row-${rowIndex}`"
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="readOnly" />
</div>
</th>
<th
v-if="isAddingColumnAllowed"
v-e="['c:column:add']"
class="cursor-pointer"
@click.stop="addColumnDropdown = true"
>
<td
key="row-index"
class="caption nc-grid-cell pl-5 pr-1"
:data-testid="`cell-Id-${rowIndex}`"
@contextmenu="contextMenuTarget = null"
<a-dropdown
v-model:visible="addColumnDropdown"
:trigger="['click']"
overlay-class-name="nc-dropdown-grid-add-column"
>
<div class="items-center flex gap-1 min-w-[60px]">
<div
v-if="!readOnly || !isLocked"
class="nc-row-no text-xs text-gray-500"
:class="{ toggle: !readOnly, hidden: row.rowMeta.selected }"
>
{{ ((paginationData.page ?? 1) - 1) * (paginationData.pageSize ?? 25) + rowIndex + 1 }}
</div>
<div
v-if="!readOnly"
:class="{ hidden: !row.rowMeta.selected, flex: row.rowMeta.selected }"
class="nc-row-expand-and-checkbox"
>
<a-checkbox v-model:checked="row.rowMeta.selected" />
</div>
<span class="flex-1" />
<div class="h-full w-[60px] flex items-center justify-center">
<component :is="iconMap.plus" class="text-sm nc-column-add" />
</div>
<div
v-if="!readOnly || hasRole('commenter', true) || hasRole('viewer', true)"
class="nc-expand"
:data-testid="`nc-expand-${rowIndex}`"
:class="{ 'nc-comment': row.rowMeta?.commentCount }"
>
<a-spin
v-if="row.rowMeta.saving"
class="!flex items-center"
:data-testid="`row-save-spinner-${rowIndex}`"
/>
<template v-else>
<span
v-if="row.rowMeta?.commentCount"
class="py-1 px-3 rounded-full text-xs cursor-pointer select-none transform hover:(scale-110)"
:style="{ backgroundColor: enumColor.light[row.rowMeta.commentCount % enumColor.light.length] }"
@click="expandForm(row, state)"
>
{{ row.rowMeta.commentCount }}
</span>
<div
v-else
class="cursor-pointer flex items-center border-1 active:ring rounded p-1 hover:(bg-primary bg-opacity-10)"
>
<component
:is="iconMap.expand"
v-e="['c:row-expand']"
class="select-none transform hover:(text-accent scale-120) nc-row-expand"
<template #overlay>
<SmartsheetColumnEditOrAddProvider
v-if="addColumnDropdown"
:preload="preloadColumn"
:column-position="columnOrder"
@submit="closeAddColumnDropdown(true)"
@cancel="closeAddColumnDropdown()"
@click.stop
@keydown.stop
/>
</template>
</a-dropdown>
</th>
</tr>
</thead>
<tbody ref="tableBodyEl">
<LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row">
<template #default="{ state }">
<tr
class="nc-grid-row"
:style="{ height: rowHeight ? `${rowHeight * 1.8}rem` : `1.8rem` }"
:data-testid="`grid-row-${rowIndex}`"
>
<td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-testid="`cell-Id-${rowIndex}`">
<div class="items-center flex gap-1 min-w-[60px]">
<div
v-if="!readOnly || !isLocked"
class="nc-row-no text-xs text-gray-500"
:class="{ toggle: !readOnly, hidden: row.rowMeta.selected }"
>
{{ ((paginationData.page ?? 1) - 1) * (paginationData.pageSize ?? 25) + rowIndex + 1 }}
</div>
<div
v-if="!readOnly"
:class="{ hidden: !row.rowMeta.selected, flex: row.rowMeta.selected }"
class="nc-row-expand-and-checkbox"
>
<a-checkbox v-model:checked="row.rowMeta.selected" />
</div>
<span class="flex-1" />
<div
v-if="!readOnly || hasRole('commenter', true) || hasRole('viewer', true)"
class="nc-expand"
:data-testid="`nc-expand-${rowIndex}`"
:class="{ 'nc-comment': row.rowMeta?.commentCount }"
>
<a-spin
v-if="row.rowMeta.saving"
class="!flex items-center"
:data-testid="`row-save-spinner-${rowIndex}`"
/>
<template v-else>
<span
v-if="row.rowMeta?.commentCount"
class="py-1 px-3 rounded-full text-xs cursor-pointer select-none transform hover:(scale-110)"
:style="{ backgroundColor: enumColor.light[row.rowMeta.commentCount % enumColor.light.length] }"
@click="expandForm(row, state)"
/>
</div>
</template>
>
{{ row.rowMeta.commentCount }}
</span>
<div
v-else
class="cursor-pointer flex items-center border-1 active:ring rounded p-1 hover:(bg-primary bg-opacity-10)"
>
<component
:is="iconMap.expand"
v-e="['c:row-expand']"
class="select-none transform hover:(text-accent scale-120) nc-row-expand"
@click="expandForm(row, state)"
/>
</div>
</template>
</div>
</div>
</div>
</td>
<SmartsheetTableDataCell
v-for="(columnObj, colIndex) of fields"
:key="columnObj.id"
class="cell relative nc-grid-cell"
:class="{
'cursor-pointer': hasEditPermission,
'active': hasEditPermission && isCellSelected(rowIndex, colIndex),
'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row),
'align-middle': !rowHeight || rowHeight === 1,
'align-top': rowHeight && rowHeight !== 1,
}"
:data-testid="`cell-${columnObj.title}-${rowIndex}`"
:data-key="rowIndex + columnObj.id"
:data-col="columnObj.id"
:data-title="columnObj.title"
@mousedown="handleMouseDown($event, rowIndex, colIndex)"
@mouseover="handleMouseOver($event, rowIndex, colIndex)"
@click="handleCellClick($event, rowIndex, colIndex)"
@dblclick="makeEditable(row, columnObj)"
@contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })"
>
<div v-if="!switchingTab" class="w-full h-full">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(columnObj)"
v-model="row.row[columnObj.title]"
:column="columnObj"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
:row="row"
:read-only="readOnly"
@navigate="onNavigate"
@save="updateOrSaveRow(row, '', state)"
/>
</td>
<SmartsheetTableDataCell
v-for="(columnObj, colIndex) of fields"
:key="columnObj.id"
ref="cellRefs"
class="cell relative nc-grid-cell"
:class="{
'cursor-pointer': hasEditPermission,
'active': hasEditPermission && isCellSelected(rowIndex, colIndex),
'active-cell':
hasEditPermission &&
((activeCell.row === rowIndex && activeCell.col === colIndex) ||
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === colIndex)),
'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row),
'align-middle': !rowHeight || rowHeight === 1,
'align-top': rowHeight && rowHeight !== 1,
'filling': isCellInFillRange(rowIndex, colIndex),
}"
:data-testid="`cell-${columnObj.title}-${rowIndex}`"
:data-key="rowIndex + columnObj.id"
:data-col="columnObj.id"
:data-title="columnObj.title"
:data-row-index="rowIndex"
:data-col-index="colIndex"
@mousedown="handleMouseDown($event, rowIndex, colIndex)"
@mouseover="handleMouseOver($event, rowIndex, colIndex)"
@click="handleCellClick($event, rowIndex, colIndex)"
@dblclick="makeEditable(row, columnObj)"
@contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })"
>
<div v-if="!switchingTab" class="w-full h-full">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(columnObj)"
v-model="row.row[columnObj.title]"
:column="columnObj"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
:row="row"
:read-only="readOnly"
@navigate="onNavigate"
@save="updateOrSaveRow(row, '', state)"
/>
<LazySmartsheetCell
v-else
v-model="row.row[columnObj.title]"
:column="columnObj"
:edit-enabled="
!!hasEditPermission && !!editEnabled && activeCell.col === colIndex && activeCell.row === rowIndex
"
:row-index="rowIndex"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
:read-only="readOnly"
@update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow(row, columnObj.title, state)"
@navigate="onNavigate"
@cancel="editEnabled = false"
/>
</div>
</SmartsheetTableDataCell>
</tr>
</template>
</LazySmartsheetRow>
<tr
v-if="isAddingEmptyRowAllowed"
v-e="['c:row:add:grid-bottom']"
class="cursor-pointer"
@mouseup.stop
@click="addEmptyRow()"
>
<td class="text-left pointer nc-grid-add-new-cell sticky left-0 !z-5 !border-r-0">
<div class="px-2 w-full flex items-center text-gray-500">
<component :is="iconMap.plus" class="text-pint-500 text-xs ml-2 text-primary" />
</div>
</td>
<td :colspan="visibleColLength"></td>
</tr>
</tbody>
</table>
<LazySmartsheetCell
v-else
v-model="row.row[columnObj.title]"
:column="columnObj"
:edit-enabled="
!!hasEditPermission && !!editEnabled && activeCell.col === colIndex && activeCell.row === rowIndex
"
:row-index="rowIndex"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
:read-only="readOnly"
@update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow(row, columnObj.title, state)"
@navigate="onNavigate"
@cancel="editEnabled = false"
/>
</div>
</SmartsheetTableDataCell>
</tr>
</template>
</LazySmartsheetRow>
<tr
v-if="isAddingEmptyRowAllowed"
v-e="['c:row:add:grid-bottom']"
class="cursor-pointer relative z-3"
@mouseup.stop
@click="addEmptyRow()"
>
<td class="text-left pointer nc-grid-add-new-cell sticky left-0 !z-5 !border-r-0">
<div class="px-2 w-full flex items-center text-gray-500">
<component :is="iconMap.plus" class="text-pint-500 text-xs ml-2 text-primary" />
</div>
</td>
<td :colspan="visibleColLength"></td>
</tr>
</tbody>
</table>
<!-- Fill Handle -->
<div
v-show="showFillHandle"
ref="fillHandle"
class="nc-fill-handle"
:class="
(!selectedRange.isEmpty() && selectedRange.end.col !== 0) || (selectedRange.isEmpty() && activeCell.col !== 0)
? 'z-3'
: 'z-4'
"
:style="{ top: `${fillHandleTop}px`, left: `${fillHandleLeft}px`, cursor: 'crosshair' }"
/>
</div>
<template v-if="!isLocked && hasEditPermission" #overlay>
<a-menu class="shadow !rounded !py-0" @click="contextMenu = false">
@ -1292,9 +1390,29 @@ function addEmptyRow(row?: number) {
// todo: replace with css variable
td.active::after {
@apply text-primary border-current bg-primary bg-opacity-5;
}
td.active-cell::after {
@apply border-1 border-solid text-primary border-current bg-primary bg-opacity-5;
}
td.filling::after {
content: '';
position: absolute;
z-index: 3;
height: calc(100% + 2px);
width: calc(100% + 2px);
left: -1px;
top: -1px;
pointer-events: none;
}
// todo: replace with css variable
td.filling::after {
@apply border-1 border-dashed text-primary border-current bg-gray-100 bg-opacity-50;
}
//td.active::before {
// content: '';
// z-index:4;
@ -1394,4 +1512,14 @@ tbody tr:hover {
.nc-required-cell {
box-shadow: inset 0 0 2px #f00;
}
.nc-fill-handle {
@apply w-[6px] h-[6px] absolute rounded-full bg-red-500 !pointer-events-auto mt-[-4px] ml-[-4px];
}
.nc-fill-handle:hover,
.nc-fill-handle:active,
.nc-fill-handle:focus {
@apply w-[8px] h-[8px] mt-[-5px] ml-[-5px];
}
</style>

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

@ -8,6 +8,8 @@ const cellClickHook = createEventHook()
provide(CellClickHookInj, cellClickHook)
provide(CurrentCellInj, el)
defineExpose({ el })
</script>
<template>

18
packages/nc-gui/composables/useMultiSelect/cellRange.ts

@ -24,6 +24,24 @@ export class CellRange {
return !this.isEmpty() && this._start?.row === this._end?.row
}
isCellInRange(cell: Cell) {
return (
!this.isEmpty() &&
cell.row >= this.start.row &&
cell.row <= this.end.row &&
cell.col >= this.start.col &&
cell.col <= this.end.col
)
}
isRowInRange(row: number) {
return !this.isEmpty() && row >= this.start.row && row <= this.end.row
}
isColInRange(col: number) {
return !this.isEmpty() && col >= this.start.col && col <= this.end.col
}
get start(): Cell {
return {
row: Math.min(this._start?.row ?? NaN, this._end?.row ?? NaN),

281
packages/nc-gui/composables/useMultiSelect/index.ts

@ -48,6 +48,7 @@ export function useMultiSelect(
keyEventHandler?: Function,
syncCellData?: Function,
bulkUpdateRows?: Function,
fillHandle?: MaybeRef<HTMLElement | undefined>,
) {
const meta = ref(_meta)
@ -59,14 +60,18 @@ export function useMultiSelect(
const { appInfo } = useGlobal()
const { isMysql } = useProject()
const { isMysql, isPg } = useProject()
const editEnabled = ref(_editEnabled)
let isMouseDown = $ref(false)
const isMouseDown = ref(false)
const isFillMode = ref(false)
const selectedRange = reactive(new CellRange())
const fillRange = reactive(new CellRange())
const activeCell = reactive<Nullable<Cell>>({ row: null, col: null })
const columnLength = $computed(() => unref(fields)?.length)
@ -123,7 +128,7 @@ export function useMultiSelect(
})
}
if (columnObj.uidt === UITypes.DateTime || columnObj.uidt === UITypes.Time) {
if (columnObj.uidt === UITypes.DateTime) {
// remove `"`
// e.g. "2023-05-12T08:03:53.000Z" -> 2023-05-12T08:03:53.000Z
textToCopy = textToCopy.replace(/["']/g, '')
@ -142,16 +147,44 @@ export function useMultiSelect(
// users can change the datetime format in UI
// `textToCopy` would be always in YYYY-MM-DD HH:mm:ss(Z / +xx:yy) format
// therefore, here we reformat to the correct datetime format based on the meta
textToCopy = d.format(
columnObj.uidt === UITypes.DateTime ? constructDateTimeFormat(columnObj) : constructTimeFormat(columnObj),
)
textToCopy = d.format(constructDateTimeFormat(columnObj))
if (!dayjs(textToCopy).isValid()) {
// return empty string for invalid datetime / time
// return empty string for invalid datetime
return ''
}
}
if (columnObj.uidt === UITypes.Time) {
// remove `"`
// e.g. "2023-05-12T08:03:53.000Z" -> 2023-05-12T08:03:53.000Z
textToCopy = textToCopy.replace(/["']/g, '')
const isMySQL = isMysql(columnObj.base_id)
const isPostgres = isPg(columnObj.base_id)
let d = dayjs(textToCopy)
if (!d.isValid()) {
// insert a datetime value, copy the value without refreshing
// e.g. textToCopy = 2023-05-12T03:49:25.000Z
// feed custom parse format
d = dayjs(textToCopy, isMySQL ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ')
}
if (!d.isValid()) {
// MySQL and Postgres store time in HH:mm:ss format so we need to feed custom parse format
d = isMySQL || isPostgres ? dayjs(textToCopy, 'HH:mm:ss') : dayjs(textToCopy)
}
if (!d.isValid()) {
// return empty string for invalid time
return ''
}
textToCopy = d.format(constructTimeFormat(columnObj))
}
if (columnObj.uidt === UITypes.LongText) {
textToCopy = `"${textToCopy.replace(/\"/g, '""')}"`
}
@ -159,23 +192,33 @@ export function useMultiSelect(
return textToCopy
}
const copyTable = async (rows: Row[], cols: ColumnType[]) => {
let copyHTML = '<table>'
let copyPlainText = ''
const serializeRange = (rows: Row[], cols: ColumnType[]) => {
let html = '<table>'
let text = ''
const json: string[][] = []
rows.forEach((row, i) => {
let copyRow = '<tr>'
const jsonRow: string[] = []
cols.forEach((col, i) => {
const value = valueToCopy(row, col)
copyRow += `<td>${value}</td>`
copyPlainText = `${copyPlainText}${value}${cols.length - 1 !== i ? '\t' : ''}`
text = `${text}${value}${cols.length - 1 !== i ? '\t' : ''}`
jsonRow.push(col.uidt === UITypes.LongText ? value.replace(/^"/, '').replace(/"$/, '').replace(/""/g, '"') : value)
})
copyHTML += `${copyRow}</tr>`
html += `${copyRow}</tr>`
if (rows.length - 1 !== i) {
copyPlainText = `${copyPlainText}\n`
text = `${text}\n`
}
json.push(jsonRow)
})
copyHTML += '</table>'
html += '</table>'
return { html, text, json }
}
const copyTable = async (rows: Row[], cols: ColumnType[]) => {
const { html: copyHTML, text: copyPlainText } = serializeRange(rows, cols)
const blobHTML = new Blob([copyHTML], { type: 'text/html' })
const blobPlainText = new Blob([copyPlainText], { type: 'text/plain' })
@ -217,20 +260,71 @@ export function useMultiSelect(
return true
}
if (selectedRange.start === null || selectedRange.end === null) {
return selectedRange.isCellInRange({ row, col })
}
function isCellInFillRange(row: number, col: number) {
if (fillRange._start === null || fillRange._end === null) {
return false
}
if (selectedRange.isCellInRange({ row, col })) {
return false
}
return fillRange.isCellInRange({ row, col })
}
const isPasteable = (row?: Row, col?: ColumnType, showInfo = false) => {
if (!row || !col) {
if (showInfo) {
message.info('Please select a cell to paste')
}
return false
}
// skip pasting virtual columns (including LTAR columns for now) and system columns
if (isVirtualCol(col) || isSystemColumn(col)) {
if (showInfo) {
message.info(t('msg.info.pasteNotSupported'))
}
return false
}
// skip pasting auto increment columns
if (col.ai) {
if (showInfo) {
message.info(t('msg.info.autoIncFieldNotEditable'))
}
return false
}
return (
col >= selectedRange.start.col &&
col <= selectedRange.end.col &&
row >= selectedRange.start.row &&
row <= selectedRange.end.row
)
// skip pasting primary key columns
if (col.pk && !row.rowMeta.new) {
if (showInfo) {
message.info(t('msg.info.editingPKnotSupported'))
}
return false
}
return true
}
function handleMouseOver(event: MouseEvent, row: number, col: number) {
if (!isMouseDown) {
if (isFillMode.value) {
const rw = unref(data)[row]
if (!selectedRange._start || !selectedRange._end) return
// fill is not supported for new rows yet
if (rw.rowMeta.new) return
fillRange.endRange({ row, col: selectedRange._end.col })
scrollToCell?.(row, col)
return
}
if (!isMouseDown.value) {
return
}
@ -252,7 +346,7 @@ export function useMultiSelect(
}
// if edit is enabled, don't start the selection (some cells shrink after edit mode, which causes the selection to expand if flag is set)
if (!editEnabled.value) isMouseDown = true
if (!editEnabled.value) isMouseDown.value = true
contextMenu.value = false
@ -290,8 +384,90 @@ export function useMultiSelect(
}
const handleMouseUp = (_event: MouseEvent) => {
if (isMouseDown) {
isMouseDown = false
if (isFillMode.value) {
isFillMode.value = false
if (fillRange._start === null || fillRange._end === null) return
if (selectedRange._start !== null && selectedRange._end !== null) {
const tempActiveCell = { row: selectedRange._start.row, col: selectedRange._start.col }
const cprows = unref(data).slice(selectedRange.start.row, selectedRange.end.row + 1) // slice the selected rows for copy
const cpcols = unref(fields).slice(selectedRange.start.col, selectedRange.end.col + 1) // slice the selected cols for copy
const rawMatrix = serializeRange(cprows, cpcols).json
const fillDirection = fillRange._start.row <= fillRange._end.row ? 1 : -1
let fillIndex = fillDirection === 1 ? 0 : rawMatrix.length - 1
const rowsToPaste: Row[] = []
const propsToPaste: string[] = []
for (
let row = fillRange._start.row;
fillDirection === 1 ? row <= fillRange._end.row : row >= fillRange._end.row;
row += fillDirection
) {
if (isCellSelected(row, selectedRange.start.col)) {
continue
}
const rowObj = unref(data)[row]
let pasteIndex = 0
for (let col = fillRange.start.col; col <= fillRange.end.col; col++) {
const colObj = unref(fields)[col]
if (!isPasteable(rowObj, colObj)) {
pasteIndex++
continue
}
propsToPaste.push(colObj.title!)
const pasteValue = convertCellData(
{
value: rawMatrix[fillIndex][pasteIndex],
to: colObj.uidt as UITypes,
column: colObj,
appInfo: unref(appInfo),
},
isMysql(meta.value?.base_id),
true,
)
if (pasteValue !== undefined) {
rowObj.row[colObj.title!] = pasteValue
rowsToPaste.push(rowObj)
}
pasteIndex++
}
if (fillDirection === 1) {
fillIndex = fillIndex < rawMatrix.length - 1 ? fillIndex + 1 : 0
} else {
fillIndex = fillIndex >= 1 ? fillIndex - 1 : rawMatrix.length - 1
}
}
bulkUpdateRows?.(rowsToPaste, propsToPaste).then(() => {
if (fillRange._start === null || fillRange._end === null) return
selectedRange.startRange(tempActiveCell)
selectedRange.endRange(fillRange._end)
makeActive(tempActiveCell.row, tempActiveCell.col)
fillRange.clear()
})
} else {
fillRange.clear()
}
return
}
if (isMouseDown.value) {
isMouseDown.value = false
// timeout is needed, because we want to set cell as active AFTER all the child's click handler's called
// this is needed e.g. for date field edit, where two clicks had to be done - one to select cell, and another one to open date dropdown
setTimeout(() => {
@ -530,41 +706,6 @@ export function useMultiSelect(
const clearSelectedRange = selectedRange.clear.bind(selectedRange)
const isPasteable = (row?: Row, col?: ColumnType, showInfo = false) => {
if (!row || !col) {
if (showInfo) {
message.info('Please select a cell to paste')
}
return false
}
// skip pasting virtual columns (including LTAR columns for now) and system columns
if (isVirtualCol(col) || isSystemColumn(col)) {
if (showInfo) {
message.info(t('msg.info.pasteNotSupported'))
}
return false
}
// skip pasting auto increment columns
if (col.ai) {
if (showInfo) {
message.info(t('msg.info.autoIncFieldNotEditable'))
}
return false
}
// skip pasting primary key columns
if (col.pk && !row.rowMeta.new) {
if (showInfo) {
message.info(t('msg.info.editingPKnotSupported'))
}
return false
}
return true
}
const handlePaste = async (e: ClipboardEvent) => {
if (isDrawerOrModalExist()) {
return
@ -751,10 +892,27 @@ export function useMultiSelect(
}
}
function fillHandleMouseDown(event: MouseEvent) {
if (event?.button !== MAIN_MOUSE_PRESSED) {
return
}
isFillMode.value = true
if (selectedRange._start && selectedRange._end) {
fillRange.startRange({ row: selectedRange._start?.row, col: selectedRange._start.col })
fillRange.endRange({ row: selectedRange._end?.row, col: selectedRange._end.col })
}
event.preventDefault()
}
useEventListener(document, 'keydown', handleKeyDown)
useEventListener(document, 'mouseup', handleMouseUp)
useEventListener(document, 'paste', handlePaste)
useEventListener(fillHandle, 'mousedown', fillHandleMouseDown)
return {
isCellActive,
handleMouseDown,
@ -767,5 +925,8 @@ export function useMultiSelect(
resetSelectedRange,
selectedRange,
makeActive,
isCellInFillRange,
isMouseDown,
isFillMode,
}
}

1
tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts

@ -23,6 +23,7 @@ export class AttachmentCellPageObject extends BasePage {
//
async addFile({ index, columnHeader, filePath }: { index?: number; columnHeader: string; filePath: string[] }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await this.get({ index, columnHeader }).click({ position: { x: 1, y: 1 } });
const attachFileAction = this.get({ index, columnHeader })
.locator('[data-testid="attachment-cell-file-picker-button"]')
.click();

1
tests/playwright/pages/Dashboard/common/Cell/RatingCell.ts

@ -15,6 +15,7 @@ export class RatingCellPageObject extends BasePage {
}
async select({ index, columnHeader, rating }: { index?: number; columnHeader: string; rating: number }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await this.waitForResponse({
uiAction: () => this.get({ index, columnHeader }).locator('.ant-rate-star > div').nth(rating).click(),
httpMethodsToMatch: ['POST', 'PATCH'],

4
tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts

@ -115,7 +115,9 @@ export class SelectOptionCellPageObject extends BasePage {
const selectCell = this.get({ index, columnHeader });
// check if cell active
if (!(await selectCell.getAttribute('class')).includes('active')) {
// drag based non-primary cell will have 'active' attribute
// primary cell with blue border will have 'active-cell' attribute
if (!(await selectCell.getAttribute('class')).includes('active-cell')) {
await selectCell.click();
}

32
tests/playwright/pages/Dashboard/common/Cell/TimeCell.ts

@ -20,4 +20,36 @@ export class TimeCellPageObject extends BasePage {
await cell.locator(`input[title="${value}"]`).waitFor({ state: 'visible' });
await expect(cell.locator(`[title="${value}"]`)).toBeVisible();
}
async selectTime({
// hour: 0 - 23
// minute: 0 - 59
// second: 0 - 59
hour,
minute,
}: {
hour: number;
minute: number;
}) {
const timePanel = await this.rootPage.locator('.ant-picker-time-panel-column');
await timePanel.nth(0).locator('.ant-picker-time-panel-cell').nth(hour).click();
await timePanel.nth(1).locator('.ant-picker-time-panel-cell').nth(minute).click();
if (hour < 12) {
await timePanel.nth(2).locator('.ant-picker-time-panel-cell').nth(0).click();
} else {
await timePanel.nth(2).locator('.ant-picker-time-panel-cell').nth(1).click();
}
}
async save() {
await this.rootPage.locator('button:has-text("Ok"):visible').click();
}
async set({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) {
const [hour, minute, _second] = value.split(':');
await this.get({ index, columnHeader }).click();
await this.get({ index, columnHeader }).click();
await this.selectTime({ hour: +hour, minute: +minute });
await this.save();
}
}

258
tests/playwright/tests/db/verticalFillHandle.spec.ts

@ -0,0 +1,258 @@
import { expect, test } from '@playwright/test';
import setup from '../../setup';
import { DashboardPage } from '../../pages/Dashboard';
import { Api } from 'nocodb-sdk';
import { createDemoTable } from '../../setup/demoTable';
import { BulkUpdatePage } from '../../pages/Dashboard/BulkUpdate';
let dashboard: DashboardPage;
let context: any;
let api: Api<any>;
let table;
async function dragDrop({ firstColumn, lastColumn }: { firstColumn: string; lastColumn: string }) {
await dashboard.grid.cell.get({ index: 0, columnHeader: firstColumn }).click();
await dashboard.rootPage.keyboard.press(
(await dashboard.grid.isMacOs()) ? 'Meta+Shift+ArrowRight' : 'Control+Shift+ArrowRight'
);
// get fill handle locator
const src = await dashboard.rootPage.locator(`.nc-fill-handle`);
const dst = await dashboard.grid.cell.get({ index: 3, columnHeader: lastColumn });
// drag and drop
await src.dragTo(dst);
}
async function beforeEachInit({ page, tableType }: { page: any; tableType: string }) {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
table = await createDemoTable({ context, type: tableType, recordCnt: 10 });
await page.reload();
await dashboard.treeView.openTable({ title: tableType });
}
test.describe('Fill Handle', () => {
test.beforeEach(async ({ page }) => {
await beforeEachInit({ page, tableType: 'textBased' });
});
test('Text based', async () => {
const fields = [
{ title: 'SingleLineText', value: 'Afghanistan', type: 'text' },
{ title: 'Email', value: 'jbutt@gmail.com', type: 'text' },
{ title: 'PhoneNumber', value: '1-541-754-3010', type: 'text' },
{ title: 'URL', value: 'https://www.google.com', type: 'text' },
{ title: 'MultiLineText', value: 'Aberdeen, United Kingdom', type: 'longText' },
];
await dragDrop({ firstColumn: 'SingleLineText', lastColumn: 'URL' });
// verify data on grid (verifying just two rows)
for (let i = 0; i < fields.length; i++) {
for (let j = 0; j < 4; j++) {
await dashboard.grid.cell.verify({ index: j, columnHeader: fields[i].title, value: fields[i].value });
}
}
// verify api response
const updatedRecords = (await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 4 })).list;
for (let i = 0; i < updatedRecords.length; i++) {
for (let j = 0; j < fields.length; j++) {
expect(updatedRecords[i][fields[j].title]).toEqual(fields[j].value);
}
}
});
});
test.describe('Fill Handle', () => {
test.beforeEach(async ({ page }) => {
await beforeEachInit({ page, tableType: 'numberBased' });
});
test('Number based', async () => {
const fields = [
{ title: 'Number', value: '33', type: 'text' },
{ title: 'Decimal', value: '33.3', type: 'text' },
{ title: 'Currency', value: '33.30', type: 'text' },
{ title: 'Percent', value: '33', type: 'text' },
{ title: 'Duration', value: '00:01', type: 'text' },
{ title: 'Rating', value: '3', type: 'rating' },
{ title: 'Year', value: '2023', type: 'year' },
{ title: 'Time', value: '02:02', type: 'time' },
];
// kludge: insert time from browser until mysql issue with timezone is fixed
await dashboard.grid.cell.time.set({ index: 0, columnHeader: 'Time', value: '02:02' });
// set rating for first record
await dashboard.grid.cell.rating.select({ index: 0, columnHeader: 'Rating', rating: 2 });
await dragDrop({ firstColumn: 'Number', lastColumn: 'Time' });
// verify data on grid
for (let i = 0; i < fields.length; i++) {
for (let j = 0; j < 4; j++) {
if (fields[i].type === 'rating') {
await dashboard.grid.cell.rating.verify({
index: j,
columnHeader: fields[i].title,
rating: +fields[i].value,
});
} else if (fields[i].type === 'year') {
await dashboard.grid.cell.year.verify({ index: j, columnHeader: fields[i].title, value: +fields[i].value });
} else if (fields[i].type === 'time') {
await dashboard.grid.cell.time.verify({ index: j, columnHeader: fields[i].title, value: fields[i].value });
} else {
await dashboard.grid.cell.verify({ index: j, columnHeader: fields[i].title, value: fields[i].value });
}
}
}
// verify api response
// duration in seconds
const APIResponse = [33, 33.3, 33.3, 33, 60, 3, 2023, '02:02:00'];
const updatedRecords = (await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 4 })).list;
for (let i = 0; i < updatedRecords.length; i++) {
for (let j = 0; j < fields.length; j++) {
if (fields[j].title === 'Time') {
expect(updatedRecords[i][fields[j].title]).toContain(APIResponse[j]);
} else {
expect(+updatedRecords[i][fields[j].title]).toEqual(APIResponse[j]);
}
}
}
});
});
test.describe('Fill Handle', () => {
test.beforeEach(async ({ page }) => {
await beforeEachInit({ page, tableType: 'selectBased' });
});
test('Select based', async () => {
const fields = [
{ title: 'SingleSelect', value: 'jan', type: 'singleSelect' },
{ title: 'MultiSelect', value: 'jan,feb,mar', type: 'multiSelect' },
];
await dragDrop({ firstColumn: 'SingleSelect', lastColumn: 'MultiSelect' });
// verify data on grid
const displayOptions = ['jan', 'feb', 'mar'];
for (let i = 0; i < fields.length; i++) {
for (let j = 0; j < 4; j++) {
if (fields[i].type === 'singleSelect') {
await dashboard.grid.cell.selectOption.verify({
index: j,
columnHeader: fields[i].title,
option: fields[i].value,
});
} else {
await dashboard.grid.cell.selectOption.verifyOptions({
index: j,
columnHeader: fields[i].title,
options: displayOptions,
});
}
}
}
// verify api response
const updatedRecords = (await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 4 })).list;
for (let i = 0; i < updatedRecords.length; i++) {
for (let j = 0; j < fields.length; j++) {
expect(updatedRecords[i][fields[j].title]).toContain(fields[j].value);
}
}
});
});
test.describe('Fill Handle', () => {
test.beforeEach(async ({ page }) => {
await beforeEachInit({ page, tableType: 'miscellaneous' });
});
test('Miscellaneous (Checkbox, attachment)', async () => {
const fields = [
{ title: 'Checkbox', value: 'true', type: 'checkbox' },
{ title: 'Attachment', value: `${process.cwd()}/fixtures/sampleFiles/1.json`, type: 'attachment' },
];
await dashboard.grid.cell.checkbox.click({ index: 0, columnHeader: 'Checkbox' });
const filepath = [`${process.cwd()}/fixtures/sampleFiles/1.json`];
await dashboard.grid.cell.attachment.addFile({
index: 0,
columnHeader: 'Attachment',
filePath: filepath,
});
await dragDrop({ firstColumn: 'Checkbox', lastColumn: 'Attachment' });
// verify data on grid
for (let i = 0; i < fields.length; i++) {
for (let j = 0; j < 4; j++) {
if (fields[i].type === 'checkbox') {
await dashboard.grid.cell.checkbox.verifyChecked({
index: j,
columnHeader: fields[i].title,
});
} else {
await dashboard.grid.cell.attachment.verifyFileCount({
index: j,
columnHeader: fields[i].title,
count: 1,
});
}
}
}
// verify api response
const updatedRecords = (await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 4 })).list;
for (let i = 0; i < updatedRecords.length; i++) {
for (let j = 0; j < fields.length; j++) {
expect(+updatedRecords[i]['Checkbox']).toBe(1);
expect(updatedRecords[i]['Attachment'][0].title).toBe('1.json');
expect(updatedRecords[i]['Attachment'][0].mimetype).toBe('application/json');
}
}
});
});
test.describe('Fill Handle', () => {
test.beforeEach(async ({ page }) => {
await beforeEachInit({ page, tableType: 'dateTimeBased' });
});
test('Date Time Based', async () => {
const row0_date = await api.dbTableRow.read('noco', context.project.id, table.id, 1);
const fields = [{ title: 'Date', value: row0_date['Date'], type: 'date' }];
await dragDrop({ firstColumn: 'Date', lastColumn: 'Date' });
// verify data on grid
for (let i = 0; i < fields.length; i++) {
for (let j = 0; j < 4; j++) {
await dashboard.grid.cell.date.verify({
index: j,
columnHeader: fields[i].title,
date: fields[i].value,
});
}
}
// verify api response
const updatedRecords = (await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 4 })).list;
for (let i = 0; i < updatedRecords.length; i++) {
for (let j = 0; j < fields.length; j++) {
expect(updatedRecords[i]['Date']).toBe(fields[j].value);
}
}
});
});
Loading…
Cancel
Save