Browse Source

Merge pull request #5847 from nocodb/feat/multiple-paste

feat: multiple paste
pull/5877/head
Raju Udava 1 year ago committed by GitHub
parent
commit
40fa2c76d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      packages/nc-gui/components/cell/MultiSelect.vue
  2. 14
      packages/nc-gui/components/cell/SingleSelect.vue
  3. 2
      packages/nc-gui/components/smartsheet/Cell.vue
  4. 68
      packages/nc-gui/components/smartsheet/Grid.vue
  5. 96
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  6. 28
      packages/nc-gui/composables/useMultiSelect/copyValue.ts
  7. 647
      packages/nc-gui/composables/useMultiSelect/index.ts
  8. 119
      packages/nc-gui/composables/useViewData.ts
  9. 11
      tests/playwright/pages/Dashboard/Grid/index.ts
  10. 7
      tests/playwright/pages/Dashboard/common/Cell/DateCell.ts
  11. 10
      tests/playwright/pages/Dashboard/common/Cell/RatingCell.ts
  12. 4
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  13. 4
      tests/playwright/tests/db/cellSelection.spec.ts
  14. 426
      tests/playwright/tests/db/keyboardShortcuts.spec.ts
  15. 10
      tests/playwright/tests/utils/general.ts

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

@ -47,8 +47,6 @@ const readOnly = inject(ReadonlyInj)!
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
@ -96,7 +94,7 @@ const isOptionMissing = computed(() => {
const hasEditRoles = computed(() => hasRole('owner', true) || hasRole('creator', true) || hasRole('editor', true))
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && (active.value || editable.value))
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && active.value)
const vModel = computed({
get: () => {
@ -331,7 +329,7 @@ const selectedOpts = computed(() => {
<template>
<div class="nc-multi-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div
v-if="!editable && !active"
v-if="!active"
class="flex flex-wrap"
:style="{
'display': '-webkit-box',

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

@ -42,8 +42,6 @@ const readOnly = inject(ReadonlyInj)!
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
@ -92,7 +90,7 @@ const isOptionMissing = computed(() => {
const hasEditRoles = computed(() => hasRole('owner', true) || hasRole('creator', true) || hasRole('editor', true))
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && (active.value || editable.value))
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && active.value)
const vModel = computed({
get: () => tempSelectedOptState.value ?? modelValue,
@ -200,7 +198,7 @@ const search = () => {
// prevent propagation of keydown event if select is open
const onKeydown = (e: KeyboardEvent) => {
if (isOpen.value && (active.value || editable.value)) {
if (isOpen.value && active.value) {
e.stopPropagation()
}
if (e.key === 'Enter') {
@ -253,7 +251,7 @@ const selectedOpt = computed(() => {
<template>
<div class="h-full w-full flex items-center nc-single-select" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div v-if="!editable && !active">
<div v-if="!active">
<a-tag v-if="selectedOpt" class="rounded-tag" :color="selectedOpt.color">
<span
:style="{
@ -279,9 +277,9 @@ const selectedOpt = computed(() => {
:bordered="false"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
: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)"
:show-arrow="hasEditRoles && !readOnly && active && vModel === null"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen && active ? 'active' : ''}`"
:show-search="isOpen && active"
@select="onSelect"
@keydown="onKeydown($event)"
@search="search"

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

@ -232,8 +232,6 @@ onUnmounted(() => {
<div
v-if="(isLocked || (isPublic && readOnly && !isForm) || isSystemColumn(column)) && !isAttachment(column)"
class="nc-locked-overlay"
@click.stop.prevent
@dblclick.stop.prevent
/>
</template>
</template>

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

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { nextTick } from '@vue/runtime-core'
import type { ColumnReqType, ColumnType, GridType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import {
ActiveViewInj,
CellUrlDisableOverlayInj,
@ -104,7 +104,8 @@ const expandedFormDlg = ref(false)
const expandedFormRow = ref<Row>()
const expandedFormRowState = ref<Record<string, any>>()
const gridWrapper = ref<HTMLElement>()
const tableHead = ref<HTMLElement>()
const tableHeadEl = ref<HTMLElement>()
const tableBodyEl = ref<HTMLElement>()
const isAddingColumnAllowed = $computed(() => !readOnly.value && !isLocked.value && isUIAllowed('add-column') && !isSqlView.value)
@ -125,6 +126,7 @@ const {
navigateToSiblingRow,
getExpandedRowIndex,
deleteRangeOfRows,
bulkUpdateRows,
} = useViewData(meta, view, xWhere)
const { getMeta } = useMetas()
@ -198,7 +200,6 @@ const {
clearSelectedRange,
copyValue,
isCellActive,
tbodyEl,
resetSelectedRange,
makeActive,
selectedRange,
@ -209,6 +210,7 @@ const {
$$(editEnabled),
isPkAvail,
clearCell,
clearSelectedRangeOfCells,
makeEditable,
scrollToCell,
(e: KeyboardEvent) => {
@ -255,6 +257,9 @@ const {
if (cmdOrCtrl) {
if (!isCellActive.value) return
// cmdOrCtrl+shift handled in useMultiSelect
if (e.shiftKey) return
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
@ -337,6 +342,7 @@ const {
// update/save cell value
await updateOrSaveRow(rowObj, ctx.updatedColumnTitle || columnObj.title)
},
bulkUpdateRows,
)
function scrollToCell(row?: number | null, col?: number | null) {
@ -345,13 +351,13 @@ function scrollToCell(row?: number | null, col?: number | null) {
if (row !== null && col !== null) {
// get active cell
const rows = tbodyEl.value?.querySelectorAll('tr')
const rows = tableBodyEl.value?.querySelectorAll('tr')
const cols = rows?.[row].querySelectorAll('td')
const td = cols?.[col === 0 ? 0 : col + 1]
if (!td || !gridWrapper.value) return
const { height: headerHeight } = tableHead.value!.getBoundingClientRect()
const { height: headerHeight } = tableHeadEl.value!.getBoundingClientRect()
const tdScroll = getContainerScrollForElement(td, gridWrapper.value, { top: headerHeight, bottom: 9, right: 9 })
// if first column set left to 0 since it's sticky it will be visible and calculated value will be wrong
@ -462,7 +468,7 @@ const onXcResizing = (cn: string, event: any) => {
defineExpose({
loadData,
openColumnCreate: (data) => {
tableHead.value?.querySelector('th:last-child')?.scrollIntoView({ behavior: 'smooth' })
tableHeadEl.value?.querySelector('th:last-child')?.scrollIntoView({ behavior: 'smooth' })
setTimeout(() => {
addColumnDropdown.value = true
preloadColumn.value = data
@ -569,8 +575,38 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
}
}
async function clearSelectedRangeOfCells() {
if (!hasEditPermission) return
const start = selectedRange.start
const end = selectedRange.end
const startRow = Math.min(start.row, end.row)
const endRow = Math.max(start.row, end.row)
const startCol = Math.min(start.col, end.col)
const endCol = Math.max(start.col, end.col)
const cols = fields.value.slice(startCol, endCol + 1)
const rows = data.value.slice(startRow, endRow + 1)
const props = []
for (const row of rows) {
for (const col of cols) {
if (!row || !col || !col.title) continue
// TODO handle LinkToAnotherRecord
if (isVirtualCol(col)) continue
row.row[col.title] = null
props.push(col.title)
}
}
await bulkUpdateRows(rows, props)
}
function makeEditable(row: Row, col: ColumnType) {
if (!hasEditPermission || editEnabled || isView) {
if (!hasEditPermission || editEnabled || isView || isLocked.value || readOnly.value || isSystemColumn(col)) {
return
}
@ -592,6 +628,10 @@ function makeEditable(row: Row, col: ColumnType) {
return
}
if ([UITypes.SingleSelect, UITypes.MultiSelect].includes(col.uidt as UITypes)) {
return
}
return (editEnabled = true)
}
@ -605,7 +645,7 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
/** On clicking outside of table reset active cell */
const smartTable = ref(null)
onClickOutside(tbodyEl, (e) => {
onClickOutside(tableBodyEl, (e) => {
// do nothing if context menu was open
if (contextMenu.value) return
@ -789,7 +829,7 @@ const closeAddColumnDropdown = (scrollToLastCol = false) => {
addColumnDropdown.value = false
if (scrollToLastCol) {
setTimeout(() => {
const lastAddNewRowHeader = tableHead.value?.querySelector('th:last-child')
const lastAddNewRowHeader = tableHeadEl.value?.querySelector('th:last-child')
if (lastAddNewRowHeader) {
lastAddNewRowHeader.scrollIntoView({ behavior: 'smooth' })
}
@ -816,6 +856,7 @@ const deleteSelectedRangeOfRows = () => {
function addEmptyRow(row?: number) {
const rowObj = _addEmptyRow(row)
nextTick().then(() => {
clearSelectedRange()
makeActive(row ?? data.value.length - 1, 0)
scrollToCell?.()
})
@ -842,7 +883,7 @@ function addEmptyRow(row?: number) {
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white"
@contextmenu="showContextMenu"
>
<thead ref="tableHead">
<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">
@ -908,7 +949,7 @@ function addEmptyRow(row?: number) {
</th>
</tr>
</thead>
<tbody ref="tbodyEl">
<tbody ref="tableBodyEl">
<LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row">
<template #default="{ state }">
<tr
@ -1079,6 +1120,11 @@ function addEmptyRow(row?: number) {
<div v-e="['a:row:clear']" class="nc-project-menu-item">{{ $t('activity.clearCell') }}</div>
</a-menu-item>
<!-- Clear cell -->
<a-menu-item v-else @click="clearSelectedRangeOfCells()">
<div v-e="['a:row:clear-range']" class="nc-project-menu-item">Clear Cells</div>
</a-menu-item>
<a-menu-item v-if="contextMenuTarget && selectedRange.isSingleCell()" @click="addEmptyRow(contextMenuTarget.row + 1)">
<div v-e="['a:row:insert']" class="nc-project-menu-item">
<!-- Insert New Row -->

96
packages/nc-gui/composables/useMultiSelect/convertCellData.ts

@ -1,46 +1,71 @@
import dayjs from 'dayjs'
import type { ColumnType } from 'nocodb-sdk'
import type { ColumnType, SelectOptionsType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { AppInfo } from '~/composables/useGlobal'
import { parseProp } from '#imports'
export default function convertCellData(
args: { from: UITypes; to: UITypes; value: any; column: ColumnType; appInfo: AppInfo },
args: { to: UITypes; value: string; column: ColumnType; appInfo: AppInfo },
isMysql = false,
isMultiple = false,
) {
const { from, to, value } = args
if (from === to && ![UITypes.Attachment, UITypes.Date, UITypes.DateTime, UITypes.Time, UITypes.Year].includes(to)) {
return value
}
const { to, value, column } = args
const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
// return null if value is empty
if (value === '') return null
switch (to) {
case UITypes.Number: {
const parsedNumber = Number(value)
if (isNaN(parsedNumber)) {
throw new TypeError(`Cannot convert '${value}' to number`)
if (isMultiple) {
return null
} else {
throw new TypeError(`Cannot convert '${value}' to number`)
}
}
return parsedNumber
}
case UITypes.Rating: {
const parsedNumber = Number(value ?? 0)
if (isNaN(parsedNumber)) {
throw new TypeError(`Cannot convert '${value}' to rating`)
if (isMultiple) {
return null
} else {
throw new TypeError(`Cannot convert '${value}' to rating`)
}
}
return parsedNumber
}
case UITypes.Checkbox:
return Boolean(value)
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const strval = value.trim().toLowerCase()
if (strval === 'true' || strval === '1') return true
if (strval === 'false' || strval === '0' || strval === '') return false
}
return null
case UITypes.Date: {
const parsedDate = dayjs(value)
if (!parsedDate.isValid()) throw new Error('Not a valid date')
if (!parsedDate.isValid()) {
if (isMultiple) {
return null
} else {
throw new Error('Not a valid date')
}
}
return parsedDate.format('YYYY-MM-DD')
}
case UITypes.DateTime: {
const parsedDateTime = dayjs(value)
if (!parsedDateTime.isValid()) {
throw new Error('Not a valid datetime value')
if (isMultiple) {
return null
} else {
throw new Error('Not a valid datetime value')
}
}
return parsedDateTime.utc().format('YYYY-MM-DD HH:mm:ssZ')
}
@ -54,7 +79,11 @@ export default function convertCellData(
parsedTime = dayjs(`1999-01-01 ${value}`)
}
if (!parsedTime.isValid()) {
throw new Error('Not a valid time value')
if (isMultiple) {
return null
} else {
throw new Error('Not a valid time value')
}
}
return parsedTime.format(dateFormat)
}
@ -69,7 +98,11 @@ export default function convertCellData(
return parsedDate.format('YYYY')
}
throw new Error('Not a valid year value')
if (isMultiple) {
return null
} else {
throw new Error('Not a valid year value')
}
}
case UITypes.Attachment: {
let parsedVal
@ -77,11 +110,17 @@ export default function convertCellData(
parsedVal = parseProp(value)
parsedVal = Array.isArray(parsedVal) ? parsedVal : [parsedVal]
} catch (e) {
throw new Error('Invalid attachment data')
if (isMultiple) {
return null
} else {
throw new Error('Invalid attachment data')
}
}
if (parsedVal.some((v: any) => v && !(v.url || v.data))) {
throw new Error('Invalid attachment data')
if (parsedVal.some((v: any) => v && !(v.url || v.data || v.path))) {
return null
}
// TODO(refactor): duplicate logic in attachment/utils.ts
const defaultAttachmentMeta = {
...(args.appInfo.ee && {
@ -95,7 +134,7 @@ export default function convertCellData(
const attachmentMeta = {
...defaultAttachmentMeta,
...parseProp(args.column?.meta),
...parseProp(column?.meta),
}
const attachments = []
@ -134,12 +173,31 @@ export default function convertCellData(
return JSON.stringify(attachments)
}
case UITypes.SingleSelect:
case UITypes.MultiSelect: {
// return null if value is empty
if (value === '') return null
const availableOptions = ((column.colOptions as SelectOptionsType)?.options || []).map((o) => o.title)
const vals = value.split(',')
const validVals = vals.filter((v) => availableOptions.includes(v))
// return null if no valid values
if (validVals.length === 0) return null
return validVals.join(',')
}
case UITypes.LinkToAnotherRecord:
case UITypes.Lookup:
case UITypes.Rollup:
case UITypes.Formula:
case UITypes.QrCode:
throw new Error(`Unsupported conversion from ${from} to ${to}`)
case UITypes.QrCode: {
if (isMultiple) {
return undefined
} else {
throw new Error(`Unsupported conversion for ${to}`)
}
}
default:
return value
}

28
packages/nc-gui/composables/useMultiSelect/copyValue.ts

@ -1,28 +0,0 @@
import type { ColumnType } from 'nocodb-sdk'
import type { Row } from '~/lib'
export const copyTable = async (rows: Row[], cols: ColumnType[]) => {
let copyHTML = '<table>'
let copyPlainText = ''
rows.forEach((row) => {
let copyRow = '<tr>'
cols.forEach((col) => {
let value = (col.title && row.row[col.title]) ?? ''
if (typeof value === 'object') {
value = JSON.stringify(value)
}
copyRow += `<td>${value}</td>`
copyPlainText = `${copyPlainText} ${value} \t`
})
copyHTML += `${copyRow}</tr>`
copyPlainText = `${copyPlainText.trim()}\n`
})
copyHTML += '</table>'
copyPlainText.trim()
const blobHTML = new Blob([copyHTML], { type: 'text/html' })
const blobPlainText = new Blob([copyPlainText], { type: 'text/plain' })
return navigator.clipboard.write([new ClipboardItem({ [blobHTML.type]: blobHTML, [blobPlainText.type]: blobPlainText })])
}

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

@ -1,16 +1,17 @@
import dayjs from 'dayjs'
import type { MaybeRef } from '@vueuse/core'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { RelationTypes, UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { parse } from 'papaparse'
import type { Cell } from './cellRange'
import { CellRange } from './cellRange'
import convertCellData from './convertCellData'
import type { Nullable, Row } from '~/lib'
import {
copyTable,
dateFormats,
extractPkFromRow,
extractSdkResponseErrorMsg,
isDrawerOrModalExist,
isMac,
isTypableInputColumn,
message,
@ -39,15 +40,15 @@ export function useMultiSelect(
_editEnabled: MaybeRef<boolean>,
isPkAvail: MaybeRef<boolean | undefined>,
clearCell: Function,
clearSelectedRangeOfCells: Function,
makeEditable: Function,
scrollToActiveCell?: (row?: number | null, col?: number | null) => void,
scrollToCell?: (row?: number | null, col?: number | null) => void,
keyEventHandler?: Function,
syncCellData?: Function,
bulkUpdateRows?: Function,
) {
const meta = ref(_meta)
const tbodyEl = ref<HTMLElement>()
const { t } = useI18n()
const { copy } = useCopy()
@ -58,8 +59,6 @@ export function useMultiSelect(
const { isMysql } = useProject()
let clipboardContext = $ref<{ value: any; uidt: UITypes } | null>(null)
const editEnabled = ref(_editEnabled)
let isMouseDown = $ref(false)
@ -100,6 +99,85 @@ export function useMultiSelect(
return parseProp(column?.meta)?.time_format ?? timeFormats[0]
}
const valueToCopy = (rowObj: Row, columnObj: ColumnType) => {
let textToCopy = (columnObj.title && rowObj.row[columnObj.title]) || ''
if (columnObj.uidt === UITypes.Checkbox) {
textToCopy = !!textToCopy
}
if (typeof textToCopy === 'object') {
textToCopy = JSON.stringify(textToCopy)
}
if (columnObj.uidt === UITypes.Formula) {
textToCopy = textToCopy.replace(/\b(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2})\b/g, (d: string) => {
// TODO(timezone): retrieve the format from the corresponding column meta
// assume hh:mm at this moment
return dayjs(d).utc().local().format('YYYY-MM-DD HH:mm')
})
}
if (columnObj.uidt === UITypes.DateTime || 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)
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')
}
// 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),
)
if (!dayjs(textToCopy).isValid()) {
// return empty string for invalid datetime / time
return ''
}
}
if (columnObj.uidt === UITypes.LongText) {
textToCopy = `"${textToCopy.replace(/\"/g, '""')}"`
}
return textToCopy
}
const copyTable = async (rows: Row[], cols: ColumnType[]) => {
let copyHTML = '<table>'
let copyPlainText = ''
rows.forEach((row, i) => {
let copyRow = '<tr>'
cols.forEach((col, i) => {
const value = valueToCopy(row, col)
copyRow += `<td>${value}</td>`
copyPlainText = `${copyPlainText}${value}${cols.length - 1 !== i ? '\t' : ''}`
})
copyHTML += `${copyRow}</tr>`
if (rows.length - 1 !== i) {
copyPlainText = `${copyPlainText}\n`
}
})
copyHTML += '</table>'
const blobHTML = new Blob([copyHTML], { type: 'text/html' })
const blobPlainText = new Blob([copyPlainText], { type: 'text/plain' })
return navigator.clipboard.write([new ClipboardItem({ [blobHTML.type]: blobHTML, [blobPlainText.type]: blobPlainText })])
}
async function copyValue(ctx?: Cell) {
try {
if (selectedRange.start !== null && selectedRange.end !== null && !selectedRange.isSingleCell()) {
@ -118,51 +196,7 @@ export function useMultiSelect(
const rowObj = unref(data)[cpRow]
const columnObj = unref(fields)[cpCol]
let textToCopy = (columnObj.title && rowObj.row[columnObj.title]) || ''
if (columnObj.uidt === UITypes.Checkbox) {
textToCopy = !!textToCopy
}
if (typeof textToCopy === 'object') {
textToCopy = JSON.stringify(textToCopy)
}
if (columnObj.uidt === UITypes.Formula) {
textToCopy = textToCopy.replace(/\b(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2})\b/g, (d: string) => {
// TODO(timezone): retrieve the format from the corresponding column meta
// assume hh:mm at this moment
return dayjs(d).utc().local().format('YYYY-MM-DD HH:mm')
})
}
if (columnObj.uidt === UITypes.DateTime || 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)
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')
}
// 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),
)
if (columnObj.uidt === UITypes.DateTime && !dayjs(textToCopy).isValid()) {
throw new Error('Invalid DateTime')
}
}
const textToCopy = valueToCopy(rowObj, columnObj)
await copy(textToCopy)
message.success(t('msg.info.copiedToClipboard'))
@ -199,36 +233,65 @@ export function useMultiSelect(
function handleMouseDown(event: MouseEvent, row: number, col: number) {
// if there was a right click on selected range, don't restart the selection
if (event?.button !== MAIN_MOUSE_PRESSED && isCellSelected(row, col)) {
if (
(event?.button !== MAIN_MOUSE_PRESSED || (event?.button === MAIN_MOUSE_PRESSED && event.ctrlKey)) &&
isCellSelected(row, col)
) {
return
}
isMouseDown = true
// if shift key is pressed, don't restart the selection
if (event.shiftKey) return
selectedRange.startRange({ row, col })
if (activeCell.row !== row || activeCell.col !== col) {
// clear active cell on selection start
activeCell.row = null
activeCell.col = null
}
}
const handleCellClick = (event: MouseEvent, row: number, col: number) => {
isMouseDown = true
selectedRange.startRange({ row, col })
// if shift key is pressed, prevent selecting text
if (event.shiftKey && !unref(editEnabled)) {
event.preventDefault()
}
// if shift key is pressed, don't restart the selection (unless there is no active cell)
if (!event.shiftKey || activeCell.col === null || activeCell.row === null) {
selectedRange.startRange({ row, col })
makeActive(row, col)
}
selectedRange.endRange({ row, col })
makeActive(row, col)
scrollToActiveCell?.()
scrollToCell?.(row, col)
isMouseDown = false
}
const handleMouseUp = (event: MouseEvent) => {
// 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(() => {
makeActive(selectedRange.start.row, selectedRange.start.col)
}, 0)
// if the editEnabled is false, prevent selecting text on mouseUp
if (!unref(editEnabled)) {
event.preventDefault()
}
if (isMouseDown) {
isMouseDown = 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(() => {
// if shift key is pressed, don't change the active cell
if (event.shiftKey) return
if (selectedRange._start) {
makeActive(selectedRange._start.row, selectedRange._start.col)
}
}, 0)
isMouseDown = false
// if the editEnabled is false, prevent selecting text on mouseUp
if (!unref(editEnabled)) {
event.preventDefault()
}
}
}
const handleKeyDown = async (e: KeyboardEvent) => {
@ -237,10 +300,12 @@ export function useMultiSelect(
return true
}
if (!isCellActive.value) {
if (!isCellActive.value || activeCell.row === null || activeCell.col === null) {
return
}
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
/** on tab key press navigate through cells */
switch (e.key) {
case 'Tab':
@ -266,7 +331,7 @@ export function useMultiSelect(
editEnabled.value = false
}
}
scrollToActiveCell?.()
scrollToCell?.()
break
/** on enter key press make cell editable */
case 'Enter':
@ -278,49 +343,136 @@ export function useMultiSelect(
/** on delete key press clear cell */
case 'Delete':
e.preventDefault()
selectedRange.clear()
await clearCell(activeCell as { row: number; col: number })
if (selectedRange.isSingleCell()) {
selectedRange.clear()
await clearCell(activeCell as { row: number; col: number })
} else {
await clearSelectedRangeOfCells()
}
break
/** on arrow key press navigate through cells */
case 'ArrowRight':
e.preventDefault()
selectedRange.clear()
if (activeCell.col < unref(columnLength) - 1) {
activeCell.col++
scrollToActiveCell?.()
editEnabled.value = false
if (e.shiftKey) {
if (cmdOrCtrl) {
editEnabled.value = false
selectedRange.endRange({
row: selectedRange._end?.row ?? activeCell.row,
col: unref(columnLength) - 1,
})
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col)
} else if ((selectedRange._end?.col ?? activeCell.col) < unref(columnLength) - 1) {
editEnabled.value = false
selectedRange.endRange({
row: selectedRange._end?.row ?? activeCell.row,
col: (selectedRange._end?.col ?? activeCell.col) + 1,
})
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col)
}
} else {
selectedRange.clear()
if (activeCell.col < unref(columnLength) - 1) {
activeCell.col++
selectedRange.startRange({ row: activeCell.row, col: activeCell.col })
scrollToCell?.()
editEnabled.value = false
}
}
break
case 'ArrowLeft':
e.preventDefault()
selectedRange.clear()
if (activeCell.col > 0) {
activeCell.col--
scrollToActiveCell?.()
editEnabled.value = false
if (e.shiftKey) {
if (cmdOrCtrl) {
editEnabled.value = false
selectedRange.endRange({
row: selectedRange._end?.row ?? activeCell.row,
col: 0,
})
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col)
} else if ((selectedRange._end?.col ?? activeCell.col) > 0) {
editEnabled.value = false
selectedRange.endRange({
row: selectedRange._end?.row ?? activeCell.row,
col: (selectedRange._end?.col ?? activeCell.col) - 1,
})
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col)
}
} else {
selectedRange.clear()
if (activeCell.col > 0) {
activeCell.col--
selectedRange.startRange({ row: activeCell.row, col: activeCell.col })
scrollToCell?.()
editEnabled.value = false
}
}
break
case 'ArrowUp':
e.preventDefault()
selectedRange.clear()
if (activeCell.row > 0) {
activeCell.row--
scrollToActiveCell?.()
editEnabled.value = false
if (e.shiftKey) {
if (cmdOrCtrl) {
editEnabled.value = false
console.log(selectedRange._end?.col)
selectedRange.endRange({
row: 0,
col: selectedRange._end?.col ?? activeCell.col,
})
console.log(selectedRange._end?.col)
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col)
} else if ((selectedRange._end?.row ?? activeCell.row) > 0) {
editEnabled.value = false
selectedRange.endRange({
row: (selectedRange._end?.row ?? activeCell.row) - 1,
col: selectedRange._end?.col ?? activeCell.col,
})
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col)
}
} else {
selectedRange.clear()
if (activeCell.row > 0) {
activeCell.row--
selectedRange.startRange({ row: activeCell.row, col: activeCell.col })
scrollToCell?.()
editEnabled.value = false
}
}
break
case 'ArrowDown':
e.preventDefault()
selectedRange.clear()
if (activeCell.row < unref(data).length - 1) {
activeCell.row++
scrollToActiveCell?.()
editEnabled.value = false
if (e.shiftKey) {
if (cmdOrCtrl) {
editEnabled.value = false
selectedRange.endRange({
row: unref(data).length - 1,
col: selectedRange._end?.col ?? activeCell.col,
})
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col)
} else if ((selectedRange._end?.row ?? activeCell.row) < unref(data).length - 1) {
editEnabled.value = false
selectedRange.endRange({
row: (selectedRange._end?.row ?? activeCell.row) + 1,
col: selectedRange._end?.col ?? activeCell.col,
})
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col)
}
} else {
selectedRange.clear()
if (activeCell.row < unref(data).length - 1) {
activeCell.row++
selectedRange.startRange({ row: activeCell.row, col: activeCell.col })
scrollToCell?.()
editEnabled.value = false
}
}
break
default:
@ -328,89 +480,21 @@ export function useMultiSelect(
const rowObj = unref(data)[activeCell.row]
const columnObj = unref(fields)[activeCell.col]
if ((!unref(editEnabled) || !isTypableInputColumn(columnObj)) && (isMac() ? e.metaKey : e.ctrlKey)) {
if (
(!unref(editEnabled) || !isTypableInputColumn(columnObj)) &&
!isDrawerOrModalExist() &&
(isMac() ? e.metaKey : e.ctrlKey)
) {
switch (e.keyCode) {
// copy - ctrl/cmd +c
case 67:
// set clipboard context only if single cell selected
// or if selected range is empty
if (selectedRange.isSingleCell() || (selectedRange.isEmpty() && rowObj && columnObj)) {
clipboardContext = {
value: rowObj.row[columnObj.title!],
uidt: columnObj.uidt as UITypes,
}
} else {
clipboardContext = null
}
await copyValue()
break
// paste - ctrl/cmd + v
case 86:
try {
// if edit permission is not there, return
if (!hasEditPermission) return
// handle belongs to column
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
(columnObj.colOptions as LinkToAnotherRecordType)?.type === RelationTypes.BELONGS_TO
) {
if (!clipboardContext || typeof clipboardContext.value !== 'object') {
return message.info('Invalid data')
}
rowObj.row[columnObj.title!] = convertCellData(
{
value: clipboardContext.value,
from: clipboardContext.uidt,
to: columnObj.uidt as UITypes,
column: columnObj,
appInfo: unref(appInfo),
},
isMysql(meta.value?.base_id),
)
e.preventDefault()
const foreignKeyColumn = meta.value?.columns?.find(
(column: ColumnType) => column.id === (columnObj.colOptions as LinkToAnotherRecordType)?.fk_child_column_id,
)
const relatedTableMeta = await getMeta((columnObj.colOptions as LinkToAnotherRecordType).fk_related_model_id!)
if (!foreignKeyColumn) return
rowObj.row[foreignKeyColumn.title!] = extractPkFromRow(
clipboardContext.value,
(relatedTableMeta as any)!.columns!,
)
return await syncCellData?.({ ...activeCell, updatedColumnTitle: foreignKeyColumn.title })
}
// if it's a virtual column excluding belongs to cell type skip paste
if (isVirtualCol(columnObj)) {
return message.info(t('msg.info.pasteNotSupported'))
}
if (clipboardContext) {
rowObj.row[columnObj.title!] = convertCellData(
{
value: clipboardContext.value,
from: clipboardContext.uidt,
to: columnObj.uidt as UITypes,
column: columnObj,
appInfo: unref(appInfo),
},
isMysql(meta.value?.base_id),
)
e.preventDefault()
syncCellData?.(activeCell)
} else {
clearCell(activeCell as { row: number; col: number }, true)
makeEditable(rowObj, columnObj)
}
} catch (error: any) {
message.error(await extractSdkResponseErrorMsg(error))
}
// select all - ctrl/cmd +a
case 65:
selectedRange.startRange({ row: 0, col: 0 })
selectedRange.endRange({ row: unref(data).length - 1, col: unref(columnLength) - 1 })
break
}
}
@ -438,8 +522,230 @@ 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
}
if (!isCellActive.value) {
return
}
if (unref(editEnabled)) {
return
}
if (activeCell.row === null || activeCell.row === undefined || activeCell.col === null || activeCell.col === undefined) {
return
}
e.preventDefault()
const clipboardData = e.clipboardData?.getData('text/plain') || ''
try {
if (clipboardData?.includes('\n') || clipboardData?.includes('\t')) {
// if the clipboard data contains new line or tab, then it is a matrix or LongText
const parsedClipboard = parse(clipboardData, { delimiter: '\t' })
if (parsedClipboard.errors.length > 0) {
throw new Error(parsedClipboard.errors[0].message)
}
const clipboardMatrix = parsedClipboard.data as string[][]
const pasteMatrixRows = clipboardMatrix.length
const pasteMatrixCols = clipboardMatrix[0].length
const colsToPaste = unref(fields).slice(activeCell.col, activeCell.col + pasteMatrixCols)
const rowsToPaste = unref(data).slice(activeCell.row, activeCell.row + pasteMatrixRows)
const propsToPaste: string[] = []
let pastedRows = 0
for (let i = 0; i < pasteMatrixRows; i++) {
const pasteRow = rowsToPaste[i]
// TODO handle insert new row
if (!pasteRow || pasteRow.rowMeta.new) break
pastedRows++
for (let j = 0; j < pasteMatrixCols; j++) {
const pasteCol = colsToPaste[j]
if (!isPasteable(pasteRow, pasteCol)) {
continue
}
propsToPaste.push(pasteCol.title!)
const pasteValue = convertCellData(
{
value: clipboardMatrix[i][j],
to: pasteCol.uidt as UITypes,
column: pasteCol,
appInfo: unref(appInfo),
},
isMysql(meta.value?.base_id),
true,
)
if (pasteValue !== undefined) {
pasteRow.row[pasteCol.title!] = pasteValue
}
}
}
await bulkUpdateRows?.(rowsToPaste, propsToPaste)
if (pastedRows > 0) {
// highlight the pasted range
selectedRange.startRange({ row: activeCell.row, col: activeCell.col })
selectedRange.endRange({ row: activeCell.row + pastedRows - 1, col: activeCell.col + pasteMatrixCols - 1 })
}
} else {
if (selectedRange.isSingleCell()) {
const rowObj = unref(data)[activeCell.row]
const columnObj = unref(fields)[activeCell.col]
// handle belongs to column
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
(columnObj.colOptions as LinkToAnotherRecordType)?.type === RelationTypes.BELONGS_TO
) {
const clipboardContext = JSON.parse(clipboardData!)
rowObj.row[columnObj.title!] = convertCellData(
{
value: clipboardContext,
to: columnObj.uidt as UITypes,
column: columnObj,
appInfo: unref(appInfo),
},
isMysql(meta.value?.base_id),
)
const foreignKeyColumn = meta.value?.columns?.find(
(column: ColumnType) => column.id === (columnObj.colOptions as LinkToAnotherRecordType)?.fk_child_column_id,
)
const relatedTableMeta = await getMeta((columnObj.colOptions as LinkToAnotherRecordType).fk_related_model_id!)
if (!foreignKeyColumn) return
rowObj.row[foreignKeyColumn.title!] = extractPkFromRow(clipboardContext, (relatedTableMeta as any)!.columns!)
return await syncCellData?.({ ...activeCell, updatedColumnTitle: foreignKeyColumn.title })
}
if (!isPasteable(rowObj, columnObj, true)) {
return
}
const pasteValue = convertCellData(
{
value: clipboardData,
to: columnObj.uidt as UITypes,
column: columnObj,
appInfo: unref(appInfo),
},
isMysql(meta.value?.base_id),
)
if (pasteValue !== undefined) {
rowObj.row[columnObj.title!] = pasteValue
}
await syncCellData?.(activeCell)
} else {
const start = selectedRange.start
const end = selectedRange.end
const startRow = Math.min(start.row, end.row)
const endRow = Math.max(start.row, end.row)
const startCol = Math.min(start.col, end.col)
const endCol = Math.max(start.col, end.col)
const cols = unref(fields).slice(startCol, endCol + 1)
const rows = unref(data).slice(startRow, endRow + 1)
const props = []
for (const row of rows) {
// TODO handle insert new row
if (!row || row.rowMeta.new) continue
for (const col of cols) {
if (!col.title) continue
if (!isPasteable(row, col)) {
continue
}
props.push(col.title)
const pasteValue = convertCellData(
{
value: clipboardData,
to: col.uidt as UITypes,
column: col,
appInfo: unref(appInfo),
},
isMysql(meta.value?.base_id),
true,
)
if (pasteValue !== undefined) {
row.row[col.title] = pasteValue
}
}
}
await bulkUpdateRows?.(rows, props)
}
}
} catch (error: any) {
message.error(await extractSdkResponseErrorMsg(error))
}
}
useEventListener(document, 'keydown', handleKeyDown)
useEventListener(tbodyEl, 'mouseup', handleMouseUp)
useEventListener(document, 'mouseup', handleMouseUp)
useEventListener(document, 'paste', handlePaste)
return {
isCellActive,
@ -450,7 +756,6 @@ export function useMultiSelect(
isCellSelected,
activeCell,
handleCellClick,
tbodyEl,
resetSelectedRange,
selectedRange,
makeActive,

119
packages/nc-gui/composables/useViewData.ts

@ -448,6 +448,124 @@ export function useViewData(
}
}
async function bulkUpdateRows(
rows: Row[],
props: string[],
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
undo = false,
) {
const promises = []
for (const row of rows) {
// update changed status
if (row.rowMeta) row.rowMeta.changed = false
// if new row and save is in progress then wait until the save is complete
promises.push(until(() => !(row.rowMeta?.new && row.rowMeta?.saving)).toMatch((v) => v))
}
await Promise.all(promises)
const updateArray = []
for (const row of rows) {
if (row.rowMeta) row.rowMeta.saving = true
const pk = rowPkData(row.row, metaValue?.columns as ColumnType[])
const updateData = props.reduce((acc: Record<string, any>, prop) => {
acc[prop] = row.row[prop]
return acc
}, {} as Record<string, any>)
updateArray.push({ ...updateData, ...pk })
}
if (!undo) {
addUndo({
redo: {
fn: async function redo(redoRows: Row[], props: string[], pg: { page: number; pageSize: number }) {
await bulkUpdateRows(redoRows, props, { metaValue, viewMetaValue }, true)
if (pg.page === paginationData.value.page && pg.pageSize === paginationData.value.pageSize) {
for (const toUpdate of redoRows) {
const rowIndex = findIndexByPk(rowPkData(toUpdate.row, meta?.value?.columns as ColumnType[]))
if (rowIndex !== -1) {
const row = formattedData.value[rowIndex]
Object.assign(row.row, toUpdate.row)
Object.assign(row.oldRow, toUpdate.row)
} else {
await loadData()
break
}
}
} else {
await changePage(pg.page)
}
},
args: [clone(rows), clone(props), { page: paginationData.value.page, pageSize: paginationData.value.pageSize }],
},
undo: {
fn: async function undo(undoRows: Row[], props: string[], pg: { page: number; pageSize: number }) {
await bulkUpdateRows(undoRows, props, { metaValue, viewMetaValue }, true)
if (pg.page === paginationData.value.page && pg.pageSize === paginationData.value.pageSize) {
for (const toUpdate of undoRows) {
const rowIndex = findIndexByPk(rowPkData(toUpdate.row, meta?.value?.columns as ColumnType[]))
if (rowIndex !== -1) {
const row = formattedData.value[rowIndex]
Object.assign(row.row, toUpdate.row)
Object.assign(row.oldRow, toUpdate.row)
} else {
await loadData()
break
}
}
} else {
await changePage(pg.page)
}
},
args: [
clone(
rows.map((row) => {
return { row: row.oldRow, oldRow: row.row, rowMeta: row.rowMeta }
}),
),
props,
{ page: paginationData.value.page, pageSize: paginationData.value.pageSize },
],
},
scope: defineViewScope({ view: viewMetaValue }),
})
}
await $api.dbTableRow.bulkUpdate(NOCO, metaValue?.project_id as string, metaValue?.id as string, updateArray)
for (const row of rows) {
if (!undo) {
/** update row data(to sync formula and other related columns)
* update only formula, rollup and auto updated datetime columns data to avoid overwriting any changes made by user
*/
Object.assign(
row.row,
metaValue!.columns!.reduce<Record<string, any>>((acc: Record<string, any>, col: ColumnType) => {
if (
col.uidt === UITypes.Formula ||
col.uidt === UITypes.QrCode ||
col.uidt === UITypes.Barcode ||
col.uidt === UITypes.Rollup ||
col.au ||
col.cdf?.includes(' on update ')
)
acc[col.title!] = row.row[col.title!]
return acc
}, {} as Record<string, any>),
)
Object.assign(row.oldRow, row.row)
}
if (row.rowMeta) row.rowMeta.saving = false
}
}
async function changePage(page: number) {
paginationData.value.page = page
await loadData({
@ -807,6 +925,7 @@ export function useViewData(
deleteSelectedRows,
deleteRangeOfRows,
updateOrSaveRow,
bulkUpdateRows,
selectedAllRecords,
syncCount,
syncPagination,

11
tests/playwright/pages/Dashboard/Grid/index.ts

@ -167,12 +167,13 @@ export class GridPage extends BasePage {
await this.dashboard.waitForLoaderToDisappear();
}
async addRowRightClickMenu(index: number) {
async addRowRightClickMenu(index: number, columnHeader = 'Title') {
const rowCount = await this.get().locator('.nc-grid-row').count();
await this.get().locator(`td[data-testid="cell-Title-${index}"]`).click();
await this.get().locator(`td[data-testid="cell-Title-${index}"]`).click({
button: 'right',
});
const cell = await this.get().locator(`td[data-testid="cell-${columnHeader}-${index}"]`).last();
await cell.click();
await cell.click({ button: 'right' });
// Click text=Insert New Row
await this.rootPage.locator('text=Insert New Row').click();
await expect(await this.get().locator('.nc-grid-row')).toHaveCount(rowCount + 1);

7
tests/playwright/pages/Dashboard/common/Cell/DateCell.ts

@ -1,5 +1,6 @@
import { CellPageObject } from '.';
import BasePage from '../../../Base';
import { expect } from '@playwright/test';
export class DateCellPageObject extends BasePage {
readonly cell: CellPageObject;
@ -20,6 +21,12 @@ export class DateCellPageObject extends BasePage {
});
}
async verify({ index, columnHeader, date }: { index: number; columnHeader: string; date: string }) {
const cell = await this.get({ index, columnHeader });
await cell.scrollIntoViewIfNeeded();
await expect(cell.locator(`[title="${date}"]`)).toBeVisible();
}
async selectDate({
// date in format `YYYY-MM-DD`
date,

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

@ -22,10 +22,10 @@ export class RatingCellPageObject extends BasePage {
});
}
async verify({ index, columnHeader, rating }: { index?: number; columnHeader: string; rating: number }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await expect(await this.get({ index, columnHeader }).locator(`div[role="radio"][aria-checked="true"]`)).toHaveCount(
rating
);
async verify({ index, columnHeader, rating }: { index: number; columnHeader: string; rating: number }) {
const cell = await this.get({ index, columnHeader });
await cell.scrollIntoViewIfNeeded();
const ratingCount = await cell.locator(`li.ant-rate-star.ant-rate-star-full`).count();
await expect(ratingCount).toBe(rating);
}
}

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

@ -39,9 +39,9 @@ export class CellPageObject extends BasePage {
get({ index, columnHeader }: CellProps): Locator {
if (this.parent instanceof SharedFormPage) {
return this.parent.get().locator(`[data-testid="nc-form-input-cell-${columnHeader}"]`);
return this.parent.get().locator(`[data-testid="nc-form-input-cell-${columnHeader}"]`).first();
} else {
return this.parent.get().locator(`td[data-testid="cell-${columnHeader}-${index}"]`);
return this.parent.get().locator(`td[data-testid="cell-${columnHeader}-${index}"]`).first();
}
}

4
tests/playwright/tests/db/cellSelection.spec.ts

@ -30,7 +30,7 @@ test.describe('Verify cell selection', () => {
start: { index: 0, columnHeader: 'FirstName' },
end: { index: 1, columnHeader: 'LastName' },
});
expect(await grid.copyWithKeyboard()).toBe('MARY \t SMITH\n' + ' PATRICIA \t JOHNSON\n');
expect(await grid.copyWithKeyboard()).toBe('MARY\tSMITH\n' + 'PATRICIA\tJOHNSON');
await dashboard.closeAllTabs();
// #3 when copied with mouse, it copies correct text
@ -40,7 +40,7 @@ test.describe('Verify cell selection', () => {
end: { index: 1, columnHeader: 'LastName' },
});
expect(await grid.copyWithMouse({ index: 0, columnHeader: 'FirstName' })).toBe(
'MARY \t SMITH\n' + ' PATRICIA \t JOHNSON\n'
'MARY\tSMITH\n' + 'PATRICIA\tJOHNSON'
);
await dashboard.closeAllTabs();
});

426
tests/playwright/tests/db/keyboardShortcuts.spec.ts

@ -104,8 +104,18 @@ test.describe('Verify shortcuts', () => {
await page.waitForTimeout(2000);
await grid.cell.verify({ index: 1, columnHeader: 'Country', value: 'NewAlgeria' });
});
});
test.describe('Clipboard support', () => {
const today = new Date().toISOString().slice(0, 10);
let dashboard: DashboardPage, grid: GridPage;
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
test('Clipboard support for cells', async () => {
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
@ -114,98 +124,26 @@ test.describe('Verify shortcuts', () => {
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'SingleLineText',
title: 'SingleLineText',
uidt: UITypes.SingleLineText,
},
{
column_name: 'LongText',
title: 'LongText',
uidt: UITypes.LongText,
},
{
column_name: 'Number',
title: 'Number',
uidt: UITypes.Number,
},
{
column_name: 'PhoneNumber',
title: 'PhoneNumber',
uidt: UITypes.PhoneNumber,
},
{
column_name: 'Email',
title: 'Email',
uidt: UITypes.Email,
},
{
column_name: 'URL',
title: 'URL',
uidt: UITypes.URL,
},
{
column_name: 'Decimal',
title: 'Decimal',
uidt: UITypes.Decimal,
},
{
column_name: 'Percent',
title: 'Percent',
uidt: UITypes.Percent,
},
{
column_name: 'Currency',
title: 'Currency',
uidt: UITypes.Currency,
},
{
column_name: 'Duration',
title: 'Duration',
uidt: UITypes.Duration,
},
{
column_name: 'SingleSelect',
title: 'SingleSelect',
uidt: UITypes.SingleSelect,
dtxp: "'Option1','Option2'",
},
{
column_name: 'MultiSelect',
title: 'MultiSelect',
uidt: UITypes.MultiSelect,
dtxp: "'Option1','Option2'",
},
{
column_name: 'Rating',
title: 'Rating',
uidt: UITypes.Rating,
},
{
column_name: 'Checkbox',
title: 'Checkbox',
uidt: UITypes.Checkbox,
},
{
column_name: 'Date',
title: 'Date',
uidt: UITypes.Date,
},
{
column_name: 'Attachment',
title: 'Attachment',
uidt: UITypes.Attachment,
},
{ column_name: 'Id', uidt: UITypes.ID },
{ column_name: 'SingleLineText', uidt: UITypes.SingleLineText },
{ column_name: 'LongText', uidt: UITypes.LongText },
{ column_name: 'Number', uidt: UITypes.Number },
{ column_name: 'PhoneNumber', uidt: UITypes.PhoneNumber },
{ column_name: 'Email', uidt: UITypes.Email },
{ column_name: 'URL', uidt: UITypes.URL },
{ column_name: 'Decimal', uidt: UITypes.Decimal },
{ column_name: 'Percent', uidt: UITypes.Percent },
{ column_name: 'Currency', uidt: UITypes.Currency },
{ column_name: 'Duration', uidt: UITypes.Duration },
{ column_name: 'SingleSelect', uidt: UITypes.SingleSelect, dtxp: "'Option1','Option2'" },
{ column_name: 'MultiSelect', uidt: UITypes.MultiSelect, dtxp: "'Option1','Option2'" },
{ column_name: 'Rating', uidt: UITypes.Rating },
{ column_name: 'Checkbox', uidt: UITypes.Checkbox },
{ column_name: 'Date', uidt: UITypes.Date },
{ column_name: 'Attachment', uidt: UITypes.Attachment },
];
const today = new Date().toISOString().slice(0, 10);
const record = {
Id: 1,
SingleLineText: 'SingleLineText',
LongText: 'LongText',
SingleSelect: 'Option1',
@ -239,10 +177,8 @@ test.describe('Verify shortcuts', () => {
// reload page
await dashboard.rootPage.reload();
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'Sheet1' });
// ########################################
@ -252,147 +188,201 @@ test.describe('Verify shortcuts', () => {
columnHeader: 'Attachment',
filePath: `${process.cwd()}/fixtures/sampleFiles/1.json`,
});
});
async function verifyCellContents({ rowIndex }: { rowIndex: number }) {
const responseTable = [
{ type: 'SingleLineText', value: 'SingleLineText' },
{ type: 'LongText', value: 'LongText' },
{ type: 'SingleSelect', value: 'Option1' },
{ type: 'MultiSelect', value: `Option1Option2` },
{ type: 'Number', value: '123' },
{ type: 'PhoneNumber', value: '987654321' },
{ type: 'Email', value: 'test@example.com' },
{ type: 'URL', value: 'nocodb.com' },
{ type: 'Decimal', value: '1.12' },
{ type: 'Percent', value: '80' },
{ type: 'Currency', value: 20 },
{ type: 'Duration', value: '00:08' },
{ type: 'Rating', value: 4 },
{ type: 'Checkbox', value: 'true' },
{ type: 'Date', value: today },
{ type: 'Attachment', value: 1 },
];
// ########################################
for (const { type, value } of responseTable) {
if (type === 'Rating') {
await dashboard.grid.cell.rating.verify({
index: rowIndex,
columnHeader: type,
rating: value,
});
} else if (type === 'Checkbox') {
await dashboard.grid.cell.checkbox.verifyChecked({
index: rowIndex,
columnHeader: type,
});
} else if (type === 'Date') {
await dashboard.grid.cell.date.verify({
index: rowIndex,
columnHeader: type,
date: value,
});
} else if (type === 'Attachment') {
await dashboard.grid.cell.attachment.verifyFileCount({
index: rowIndex,
columnHeader: type,
count: value,
});
} else {
await dashboard.grid.cell.verify({
index: rowIndex,
columnHeader: type,
value,
});
}
}
}
async function verifyClipContents({ rowIndex }: { rowIndex: number }) {
const responseTable = [
{ type: 'SingleLineText', value: 'SingleLineText' },
{ type: 'LongText', value: '"LongText"' },
{ type: 'SingleSelect', value: 'Option1' },
{ type: 'MultiSelect', value: 'Option1,Option2' },
{ type: 'Number', value: '123' },
{ type: 'PhoneNumber', value: '987654321' },
{ type: 'Email', value: 'test@example.com' },
{ type: 'URL', value: 'nocodb.com' },
{ type: 'Decimal', value: '1.12' },
{ type: 'Percent', value: '80' },
{ type: 'Currency', value: 20, options: { parseInt: true } },
{ type: 'Duration', value: 480, options: { parseInt: true } },
{ type: 'Rating', value: '4' },
{ type: 'Checkbox', value: 'true' },
{ type: 'Date', value: today },
{ type: 'Attachment', value: '1.json', options: { jsonParse: true } },
];
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'SingleLineText',
});
expect(await dashboard.grid.cell.getClipboardText()).toBe('SingleLineText');
for (const { type, value, options } of responseTable) {
await dashboard.grid.cell.copyToClipboard(
{
index: rowIndex,
columnHeader: type,
},
{ position: { x: 1, y: 1 } }
);
if (options?.parseInt) {
expect(parseInt(await dashboard.grid.cell.getClipboardText())).toBe(value);
} else if (options?.jsonParse) {
const attachmentsInfo = JSON.parse(await dashboard.grid.cell.getClipboardText());
expect(attachmentsInfo[0]['title']).toBe('1.json');
} else {
expect(await dashboard.grid.cell.getClipboardText()).toBe(value);
}
}
}
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'LongText',
});
expect(await dashboard.grid.cell.getClipboardText()).toBe('LongText');
test('single cell- all data types', async () => {
await verifyClipContents({ rowIndex: 0 });
});
await dashboard.grid.cell.copyToClipboard(
{
index: 0,
columnHeader: 'SingleSelect',
},
{ position: { x: 1, y: 1 } }
);
expect(await dashboard.grid.cell.getClipboardText()).toBe('Option1');
await dashboard.grid.cell.copyToClipboard(
{
index: 0,
columnHeader: 'MultiSelect',
},
{ position: { x: 1, y: 1 } }
);
expect(await dashboard.grid.cell.getClipboardText()).toContain('Option1');
expect(await dashboard.grid.cell.getClipboardText()).toContain('Option2');
test('multiple cells - horizontal, all data types', async ({ page }) => {
// click first cell, press `Ctrl A` and `Ctrl C`
await grid.cell.click({ index: 0, columnHeader: 'Id' });
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+a' : 'Control+a');
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c');
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'SingleLineText',
});
expect(await dashboard.grid.cell.getClipboardText()).toBe('SingleLineText');
/////////////////////////////////////////////////////////////////////////
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'Number',
});
expect(await dashboard.grid.cell.getClipboardText()).toBe('123');
// horizontal multiple cells selection : copy paste
// add new row, click on first cell, paste
await grid.addNewRow({ index: 1, columnHeader: 'SingleLineText', value: 'aaa' });
await dashboard.rootPage.waitForTimeout(1000);
await grid.cell.click({ index: 1, columnHeader: 'SingleLineText' });
await page.keyboard.press('ArrowLeft');
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v');
await verifyCellContents({ rowIndex: 1 });
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'PhoneNumber',
});
expect(await dashboard.grid.cell.getClipboardText()).toBe('987654321');
// reload page
await dashboard.rootPage.reload();
await dashboard.grid.verifyRowCount({ count: 2 });
});
await dashboard.grid.cell.copyToClipboard(
{
index: 0,
columnHeader: 'Email',
},
{ position: { x: 1, y: 1 } }
);
expect(await dashboard.grid.cell.getClipboardText()).toBe('test@example.com');
await dashboard.grid.cell.copyToClipboard(
{
index: 0,
columnHeader: 'URL',
},
{ position: { x: 1, y: 1 } }
);
expect(await dashboard.grid.cell.getClipboardText()).toBe('nocodb.com');
test('multiple cells - vertical', async ({ page }) => {
let cellText: string[] = ['aaa', 'bbb', 'ccc', 'ddd', 'eee'];
for (let i = 1; i <= 5; i++) {
await grid.addNewRow({ index: i, columnHeader: 'SingleLineText', value: cellText[i - 1] });
}
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'Decimal',
});
expect(await dashboard.grid.cell.getClipboardText()).toBe('1.12');
await grid.cell.click({ index: 1, columnHeader: 'SingleLineText' });
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'Percent',
});
expect(await dashboard.grid.cell.getClipboardText()).toBe('80');
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c');
await grid.cell.click({ index: 1, columnHeader: 'LongText' });
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v');
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'Currency',
});
// convert from string to integer
expect(parseInt(await dashboard.grid.cell.getClipboardText())).toBe(20);
// reload page
await dashboard.rootPage.reload();
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'Duration',
});
expect(parseInt(await dashboard.grid.cell.getClipboardText())).toBe(480);
// verify copied data
for (let i = 1; i <= 5; i++) {
await grid.cell.verify({ index: i, columnHeader: 'LongText', value: cellText[i - 1] });
}
await dashboard.grid.cell.copyToClipboard(
{
index: 0,
columnHeader: 'Rating',
},
{ position: { x: 1, y: 1 } }
);
expect(await dashboard.grid.cell.getClipboardText()).toBe('4');
await dashboard.grid.cell.copyToClipboard(
{
index: 0,
columnHeader: 'Checkbox',
},
{ position: { x: 1, y: 1 } }
);
// await new Promise(resolve => setTimeout(resolve, 5000));
expect(await dashboard.grid.cell.getClipboardText()).toBe('true');
// Block selection
await grid.cell.click({ index: 1, columnHeader: 'SingleLineText' });
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowRight');
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c');
await grid.cell.click({ index: 4, columnHeader: 'SingleLineText' });
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v');
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'Checkbox',
});
await dashboard.grid.cell.copyToClipboard(
{
index: 0,
columnHeader: 'Checkbox',
},
{ position: { x: 1, y: 1 } }
);
expect(await dashboard.grid.cell.getClipboardText()).toBe('false');
// reload page
await dashboard.rootPage.reload();
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'Date',
});
expect(await dashboard.grid.cell.getClipboardText()).toBe(today);
// verify copied data
for (let i = 4; i <= 5; i++) {
await grid.cell.verify({ index: i, columnHeader: 'SingleLineText', value: cellText[i - 4] });
await grid.cell.verify({ index: i, columnHeader: 'LongText', value: cellText[i - 4] });
}
await dashboard.grid.cell.copyToClipboard(
{
index: 0,
columnHeader: 'Attachment',
},
{ position: { x: 1, y: 1 } }
);
const attachmentsInfo = JSON.parse(await dashboard.grid.cell.getClipboardText());
expect(attachmentsInfo[0]['title']).toBe('1.json');
/////////////////////////////////////////////////////////////////////////
// Meta for block selection
await grid.cell.click({ index: 1, columnHeader: 'SingleLineText' });
await page.keyboard.press(`Shift+${(await grid.isMacOs()) ? 'Meta' : 'Control'}+ArrowDown`);
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c');
await grid.cell.click({ index: 1, columnHeader: 'Email' });
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v');
// reload page
await dashboard.rootPage.reload();
// verify copied data
// modified cell text after previous block operation
cellText = ['aaa', 'bbb', 'ccc', 'aaa', 'bbb'];
for (let i = 1; i <= 5; i++) {
await grid.cell.verify({ index: i, columnHeader: 'Email', value: cellText[i - 1] });
}
// One copy, multiple paste
await grid.cell.click({ index: 0, columnHeader: 'SingleLineText' });
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c');
await grid.cell.click({ index: 1, columnHeader: 'SingleLineText' });
await page.keyboard.press(`Shift+${(await grid.isMacOs()) ? 'Meta' : 'Control'}+ArrowDown`);
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v');
// reload page
await dashboard.rootPage.reload();
// verify copied data
for (let i = 1; i <= 5; i++) {
await grid.cell.verify({ index: i, columnHeader: 'SingleLineText', value: 'SingleLineText' });
}
});
});

10
tests/playwright/tests/utils/general.ts

@ -60,4 +60,12 @@ function getBrowserTimezoneOffset() {
return formattedOffset;
}
export { getTextExcludeIconText, isSubset, getIconText, getDefaultPwd, getBrowserTimezoneOffset };
async function keyPress(selector, key) {
const isMac = (await selector.evaluate(() => navigator.platform)).includes('Mac') ? true : false;
if (false === isMac) {
key.replace('Meta', 'Control');
}
await selector.keyboard.press(key);
}
export { getTextExcludeIconText, isSubset, getIconText, getDefaultPwd, getBrowserTimezoneOffset, keyPress };

Loading…
Cancel
Save