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. 146
      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) => { (next) => {
if (next) { if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false)) onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false))
} else {
editable.value = false
} }
}, },
{ flush: 'post' }, { flush: 'post' },

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

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

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

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

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

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

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

@ -101,6 +101,8 @@ const contextMenu = computed({
}) })
const contextMenuClosing = ref(false) const contextMenuClosing = ref(false)
const scrolling = ref(false)
const bulkUpdateDlg = ref(false) const bulkUpdateDlg = ref(false)
const routeQuery = $computed(() => route.query as Record<string, string>) const routeQuery = $computed(() => route.query as Record<string, string>)
@ -111,6 +113,9 @@ const expandedFormRowState = ref<Record<string, any>>()
const gridWrapper = ref<HTMLElement>() const gridWrapper = ref<HTMLElement>()
const tableHeadEl = ref<HTMLElement>() const tableHeadEl = ref<HTMLElement>()
const tableBodyEl = 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) const isAddingColumnAllowed = $computed(() => !readOnly.value && !isLocked.value && isUIAllowed('add-column') && !isSqlView.value)
@ -209,6 +214,8 @@ const {
resetSelectedRange, resetSelectedRange,
makeActive, makeActive,
selectedRange, selectedRange,
isCellInFillRange,
isFillMode,
} = useMultiSelect( } = useMultiSelect(
meta, meta,
fields, fields,
@ -350,6 +357,7 @@ const {
await updateOrSaveRow(rowObj, ctx.updatedColumnTitle || columnObj.title) await updateOrSaveRow(rowObj, ctx.updatedColumnTitle || columnObj.title)
}, },
bulkUpdateRows, bulkUpdateRows,
fillHandle,
) )
function scrollToCell(row?: number | null, col?: number | null) { 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) const smartTable = ref(null)
/** On clicking outside of table reset active cell */
onClickOutside(tableBodyEl, (e) => { onClickOutside(tableBodyEl, (e) => {
// do nothing if mousedown on the scrollbar (scrolling)
if (scrolling.value) {
return
}
// do nothing if context menu was open // do nothing if context menu was open
if (contextMenu.value) return if (contextMenu.value) return
@ -665,6 +678,9 @@ onClickOutside(tableBodyEl, (e) => {
if (editEnabled && (isVirtualCol(activeCol) || activeCol.uidt === UITypes.JSON)) return 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) // ignore unselecting if clicked inside or on the picker(Date, Time, DateTime, Year)
// or single/multi select options // or single/multi select options
const activePickerOrDropdownEl = document.querySelector( const activePickerOrDropdownEl = document.querySelector(
@ -865,6 +881,15 @@ const closeAddColumnDropdown = (scrollToLastCol = false) => {
const confirmDeleteRow = (row: number) => { const confirmDeleteRow = (row: number) => {
try { try {
deleteRow(row) deleteRow(row)
if (selectedRange.isRowInRange(row)) {
clearSelectedRange()
}
if (activeCell.row === row) {
activeCell.row = null
activeCell.col = null
}
} catch (e: any) { } catch (e: any) {
message.error(e.message) message.error(e.message)
} }
@ -883,10 +908,65 @@ function addEmptyRow(row?: number) {
nextTick().then(() => { nextTick().then(() => {
clearSelectedRange() clearSelectedRange()
makeActive(row ?? data.value.length - 1, 0) makeActive(row ?? data.value.length - 1, 0)
selectedRange.startRange({ row: activeCell.row!, col: activeCell.col! })
scrollToCell?.() scrollToCell?.()
}) })
return rowObj 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> </script>
<template> <template>
@ -897,12 +977,13 @@ function addEmptyRow(row?: number) {
</div> </div>
</general-overlay> </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 <a-dropdown
v-model:visible="contextMenu" v-model:visible="contextMenu"
:trigger="isSqlView ? [] : ['contextmenu']" :trigger="isSqlView ? [] : ['contextmenu']"
overlay-class-name="nc-dropdown-grid-context-menu" overlay-class-name="nc-dropdown-grid-context-menu"
> >
<div class="table-overlay">
<table <table
ref="smartTable" ref="smartTable"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white" class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white"
@ -982,12 +1063,7 @@ function addEmptyRow(row?: number) {
:style="{ height: rowHeight ? `${rowHeight * 1.8}rem` : `1.8rem` }" :style="{ height: rowHeight ? `${rowHeight * 1.8}rem` : `1.8rem` }"
:data-testid="`grid-row-${rowIndex}`" :data-testid="`grid-row-${rowIndex}`"
> >
<td <td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-testid="`cell-Id-${rowIndex}`">
key="row-index"
class="caption nc-grid-cell pl-5 pr-1"
:data-testid="`cell-Id-${rowIndex}`"
@contextmenu="contextMenuTarget = null"
>
<div class="items-center flex gap-1 min-w-[60px]"> <div class="items-center flex gap-1 min-w-[60px]">
<div <div
v-if="!readOnly || !isLocked" v-if="!readOnly || !isLocked"
@ -1043,18 +1119,26 @@ function addEmptyRow(row?: number) {
<SmartsheetTableDataCell <SmartsheetTableDataCell
v-for="(columnObj, colIndex) of fields" v-for="(columnObj, colIndex) of fields"
:key="columnObj.id" :key="columnObj.id"
ref="cellRefs"
class="cell relative nc-grid-cell" class="cell relative nc-grid-cell"
:class="{ :class="{
'cursor-pointer': hasEditPermission, 'cursor-pointer': hasEditPermission,
'active': hasEditPermission && isCellSelected(rowIndex, colIndex), '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), 'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row),
'align-middle': !rowHeight || rowHeight === 1, 'align-middle': !rowHeight || rowHeight === 1,
'align-top': rowHeight && rowHeight !== 1, 'align-top': rowHeight && rowHeight !== 1,
'filling': isCellInFillRange(rowIndex, colIndex),
}" }"
:data-testid="`cell-${columnObj.title}-${rowIndex}`" :data-testid="`cell-${columnObj.title}-${rowIndex}`"
:data-key="rowIndex + columnObj.id" :data-key="rowIndex + columnObj.id"
:data-col="columnObj.id" :data-col="columnObj.id"
:data-title="columnObj.title" :data-title="columnObj.title"
:data-row-index="rowIndex"
:data-col-index="colIndex"
@mousedown="handleMouseDown($event, rowIndex, colIndex)" @mousedown="handleMouseDown($event, rowIndex, colIndex)"
@mouseover="handleMouseOver($event, rowIndex, colIndex)" @mouseover="handleMouseOver($event, rowIndex, colIndex)"
@click="handleCellClick($event, rowIndex, colIndex)" @click="handleCellClick($event, rowIndex, colIndex)"
@ -1097,7 +1181,7 @@ function addEmptyRow(row?: number) {
<tr <tr
v-if="isAddingEmptyRowAllowed" v-if="isAddingEmptyRowAllowed"
v-e="['c:row:add:grid-bottom']" v-e="['c:row:add:grid-bottom']"
class="cursor-pointer" class="cursor-pointer relative z-3"
@mouseup.stop @mouseup.stop
@click="addEmptyRow()" @click="addEmptyRow()"
> >
@ -1111,6 +1195,20 @@ function addEmptyRow(row?: number) {
</tbody> </tbody>
</table> </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> <template v-if="!isLocked && hasEditPermission" #overlay>
<a-menu class="shadow !rounded !py-0" @click="contextMenu = false"> <a-menu class="shadow !rounded !py-0" @click="contextMenu = false">
<a-menu-item <a-menu-item
@ -1292,9 +1390,29 @@ function addEmptyRow(row?: number) {
// todo: replace with css variable // todo: replace with css variable
td.active::after { 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; @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 { //td.active::before {
// content: ''; // content: '';
// z-index:4; // z-index:4;
@ -1394,4 +1512,14 @@ tbody tr:hover {
.nc-required-cell { .nc-required-cell {
box-shadow: inset 0 0 2px #f00; 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> </style>

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

@ -8,6 +8,8 @@ const cellClickHook = createEventHook()
provide(CellClickHookInj, cellClickHook) provide(CellClickHookInj, cellClickHook)
provide(CurrentCellInj, el) provide(CurrentCellInj, el)
defineExpose({ el })
</script> </script>
<template> <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 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 { get start(): Cell {
return { return {
row: Math.min(this._start?.row ?? NaN, this._end?.row ?? NaN), 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, keyEventHandler?: Function,
syncCellData?: Function, syncCellData?: Function,
bulkUpdateRows?: Function, bulkUpdateRows?: Function,
fillHandle?: MaybeRef<HTMLElement | undefined>,
) { ) {
const meta = ref(_meta) const meta = ref(_meta)
@ -59,14 +60,18 @@ export function useMultiSelect(
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
const { isMysql } = useProject() const { isMysql, isPg } = useProject()
const editEnabled = ref(_editEnabled) const editEnabled = ref(_editEnabled)
let isMouseDown = $ref(false) const isMouseDown = ref(false)
const isFillMode = ref(false)
const selectedRange = reactive(new CellRange()) const selectedRange = reactive(new CellRange())
const fillRange = reactive(new CellRange())
const activeCell = reactive<Nullable<Cell>>({ row: null, col: null }) const activeCell = reactive<Nullable<Cell>>({ row: null, col: null })
const columnLength = $computed(() => unref(fields)?.length) 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 `"` // remove `"`
// e.g. "2023-05-12T08:03:53.000Z" -> 2023-05-12T08:03:53.000Z // e.g. "2023-05-12T08:03:53.000Z" -> 2023-05-12T08:03:53.000Z
textToCopy = textToCopy.replace(/["']/g, '') textToCopy = textToCopy.replace(/["']/g, '')
@ -142,16 +147,44 @@ export function useMultiSelect(
// users can change the datetime format in UI // users can change the datetime format in UI
// `textToCopy` would be always in YYYY-MM-DD HH:mm:ss(Z / +xx:yy) format // `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 // therefore, here we reformat to the correct datetime format based on the meta
textToCopy = d.format( textToCopy = d.format(constructDateTimeFormat(columnObj))
columnObj.uidt === UITypes.DateTime ? constructDateTimeFormat(columnObj) : constructTimeFormat(columnObj),
)
if (!dayjs(textToCopy).isValid()) { if (!dayjs(textToCopy).isValid()) {
// return empty string for invalid datetime / time // return empty string for invalid datetime
return '' 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) { if (columnObj.uidt === UITypes.LongText) {
textToCopy = `"${textToCopy.replace(/\"/g, '""')}"` textToCopy = `"${textToCopy.replace(/\"/g, '""')}"`
} }
@ -159,23 +192,33 @@ export function useMultiSelect(
return textToCopy return textToCopy
} }
const copyTable = async (rows: Row[], cols: ColumnType[]) => { const serializeRange = (rows: Row[], cols: ColumnType[]) => {
let copyHTML = '<table>' let html = '<table>'
let copyPlainText = '' let text = ''
const json: string[][] = []
rows.forEach((row, i) => { rows.forEach((row, i) => {
let copyRow = '<tr>' let copyRow = '<tr>'
const jsonRow: string[] = []
cols.forEach((col, i) => { cols.forEach((col, i) => {
const value = valueToCopy(row, col) const value = valueToCopy(row, col)
copyRow += `<td>${value}</td>` 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) { 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 blobHTML = new Blob([copyHTML], { type: 'text/html' })
const blobPlainText = new Blob([copyPlainText], { type: 'text/plain' }) const blobPlainText = new Blob([copyPlainText], { type: 'text/plain' })
@ -217,20 +260,71 @@ export function useMultiSelect(
return true 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 return false
} }
return ( if (selectedRange.isCellInRange({ row, col })) {
col >= selectedRange.start.col && return false
col <= selectedRange.end.col && }
row >= selectedRange.start.row &&
row <= selectedRange.end.row 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
}
// 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) { 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 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 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 contextMenu.value = false
@ -290,8 +384,90 @@ export function useMultiSelect(
} }
const handleMouseUp = (_event: MouseEvent) => { const handleMouseUp = (_event: MouseEvent) => {
if (isMouseDown) { if (isFillMode.value) {
isMouseDown = false 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 // 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 // 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(() => { setTimeout(() => {
@ -530,41 +706,6 @@ export function useMultiSelect(
const clearSelectedRange = selectedRange.clear.bind(selectedRange) 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) => { const handlePaste = async (e: ClipboardEvent) => {
if (isDrawerOrModalExist()) { if (isDrawerOrModalExist()) {
return 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, 'keydown', handleKeyDown)
useEventListener(document, 'mouseup', handleMouseUp) useEventListener(document, 'mouseup', handleMouseUp)
useEventListener(document, 'paste', handlePaste) useEventListener(document, 'paste', handlePaste)
useEventListener(fillHandle, 'mousedown', fillHandleMouseDown)
return { return {
isCellActive, isCellActive,
handleMouseDown, handleMouseDown,
@ -767,5 +925,8 @@ export function useMultiSelect(
resetSelectedRange, resetSelectedRange,
selectedRange, selectedRange,
makeActive, 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[] }) { async addFile({ index, columnHeader, filePath }: { index?: number; columnHeader: string; filePath: string[] }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded(); await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await this.get({ index, columnHeader }).click({ position: { x: 1, y: 1 } });
const attachFileAction = this.get({ index, columnHeader }) const attachFileAction = this.get({ index, columnHeader })
.locator('[data-testid="attachment-cell-file-picker-button"]') .locator('[data-testid="attachment-cell-file-picker-button"]')
.click(); .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 }) { async select({ index, columnHeader, rating }: { index?: number; columnHeader: string; rating: number }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await this.waitForResponse({ await this.waitForResponse({
uiAction: () => this.get({ index, columnHeader }).locator('.ant-rate-star > div').nth(rating).click(), uiAction: () => this.get({ index, columnHeader }).locator('.ant-rate-star > div').nth(rating).click(),
httpMethodsToMatch: ['POST', 'PATCH'], 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 }); const selectCell = this.get({ index, columnHeader });
// check if cell active // 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(); 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 cell.locator(`input[title="${value}"]`).waitFor({ state: 'visible' });
await expect(cell.locator(`[title="${value}"]`)).toBeVisible(); 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