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

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

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

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

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick } from '@vue/runtime-core' import { nextTick } from '@vue/runtime-core'
import type { ColumnReqType, ColumnType, GridType, PaginatedType, TableType, ViewType } from 'nocodb-sdk' 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 { import {
ActiveViewInj, ActiveViewInj,
CellUrlDisableOverlayInj, CellUrlDisableOverlayInj,
@ -104,7 +104,8 @@ const expandedFormDlg = ref(false)
const expandedFormRow = ref<Row>() const expandedFormRow = ref<Row>()
const expandedFormRowState = ref<Record<string, any>>() const expandedFormRowState = ref<Record<string, any>>()
const gridWrapper = ref<HTMLElement>() 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) const isAddingColumnAllowed = $computed(() => !readOnly.value && !isLocked.value && isUIAllowed('add-column') && !isSqlView.value)
@ -125,6 +126,7 @@ const {
navigateToSiblingRow, navigateToSiblingRow,
getExpandedRowIndex, getExpandedRowIndex,
deleteRangeOfRows, deleteRangeOfRows,
bulkUpdateRows,
} = useViewData(meta, view, xWhere) } = useViewData(meta, view, xWhere)
const { getMeta } = useMetas() const { getMeta } = useMetas()
@ -198,7 +200,6 @@ const {
clearSelectedRange, clearSelectedRange,
copyValue, copyValue,
isCellActive, isCellActive,
tbodyEl,
resetSelectedRange, resetSelectedRange,
makeActive, makeActive,
selectedRange, selectedRange,
@ -209,6 +210,7 @@ const {
$$(editEnabled), $$(editEnabled),
isPkAvail, isPkAvail,
clearCell, clearCell,
clearSelectedRangeOfCells,
makeEditable, makeEditable,
scrollToCell, scrollToCell,
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
@ -255,6 +257,9 @@ const {
if (cmdOrCtrl) { if (cmdOrCtrl) {
if (!isCellActive.value) return if (!isCellActive.value) return
// cmdOrCtrl+shift handled in useMultiSelect
if (e.shiftKey) return
switch (e.key) { switch (e.key) {
case 'ArrowUp': case 'ArrowUp':
e.preventDefault() e.preventDefault()
@ -337,6 +342,7 @@ const {
// update/save cell value // update/save cell value
await updateOrSaveRow(rowObj, ctx.updatedColumnTitle || columnObj.title) await updateOrSaveRow(rowObj, ctx.updatedColumnTitle || columnObj.title)
}, },
bulkUpdateRows,
) )
function scrollToCell(row?: number | null, col?: number | null) { 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) { if (row !== null && col !== null) {
// get active cell // get active cell
const rows = tbodyEl.value?.querySelectorAll('tr') const rows = tableBodyEl.value?.querySelectorAll('tr')
const cols = rows?.[row].querySelectorAll('td') const cols = rows?.[row].querySelectorAll('td')
const td = cols?.[col === 0 ? 0 : col + 1] const td = cols?.[col === 0 ? 0 : col + 1]
if (!td || !gridWrapper.value) return 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 }) 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 // 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({ defineExpose({
loadData, loadData,
openColumnCreate: (data) => { openColumnCreate: (data) => {
tableHead.value?.querySelector('th:last-child')?.scrollIntoView({ behavior: 'smooth' }) tableHeadEl.value?.querySelector('th:last-child')?.scrollIntoView({ behavior: 'smooth' })
setTimeout(() => { setTimeout(() => {
addColumnDropdown.value = true addColumnDropdown.value = true
preloadColumn.value = data 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) { function makeEditable(row: Row, col: ColumnType) {
if (!hasEditPermission || editEnabled || isView) { if (!hasEditPermission || editEnabled || isView || isLocked.value || readOnly.value || isSystemColumn(col)) {
return return
} }
@ -592,6 +628,10 @@ function makeEditable(row: Row, col: ColumnType) {
return return
} }
if ([UITypes.SingleSelect, UITypes.MultiSelect].includes(col.uidt as UITypes)) {
return
}
return (editEnabled = true) return (editEnabled = true)
} }
@ -605,7 +645,7 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
/** On clicking outside of table reset active cell */ /** On clicking outside of table reset active cell */
const smartTable = ref(null) const smartTable = ref(null)
onClickOutside(tbodyEl, (e) => { onClickOutside(tableBodyEl, (e) => {
// do nothing if context menu was open // do nothing if context menu was open
if (contextMenu.value) return if (contextMenu.value) return
@ -789,7 +829,7 @@ const closeAddColumnDropdown = (scrollToLastCol = false) => {
addColumnDropdown.value = false addColumnDropdown.value = false
if (scrollToLastCol) { if (scrollToLastCol) {
setTimeout(() => { setTimeout(() => {
const lastAddNewRowHeader = tableHead.value?.querySelector('th:last-child') const lastAddNewRowHeader = tableHeadEl.value?.querySelector('th:last-child')
if (lastAddNewRowHeader) { if (lastAddNewRowHeader) {
lastAddNewRowHeader.scrollIntoView({ behavior: 'smooth' }) lastAddNewRowHeader.scrollIntoView({ behavior: 'smooth' })
} }
@ -816,6 +856,7 @@ const deleteSelectedRangeOfRows = () => {
function addEmptyRow(row?: number) { function addEmptyRow(row?: number) {
const rowObj = _addEmptyRow(row) const rowObj = _addEmptyRow(row)
nextTick().then(() => { nextTick().then(() => {
clearSelectedRange()
makeActive(row ?? data.value.length - 1, 0) makeActive(row ?? data.value.length - 1, 0)
scrollToCell?.() scrollToCell?.()
}) })
@ -842,7 +883,7 @@ function addEmptyRow(row?: number) {
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white" class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white"
@contextmenu="showContextMenu" @contextmenu="showContextMenu"
> >
<thead ref="tableHead"> <thead ref="tableHeadEl">
<tr class="nc-grid-header"> <tr class="nc-grid-header">
<th class="w-[85px] min-w-[85px]" data-testid="grid-id-column"> <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"> <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> </th>
</tr> </tr>
</thead> </thead>
<tbody ref="tbodyEl"> <tbody ref="tableBodyEl">
<LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row"> <LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row">
<template #default="{ state }"> <template #default="{ state }">
<tr <tr
@ -1079,6 +1120,11 @@ function addEmptyRow(row?: number) {
<div v-e="['a:row:clear']" class="nc-project-menu-item">{{ $t('activity.clearCell') }}</div> <div v-e="['a:row:clear']" class="nc-project-menu-item">{{ $t('activity.clearCell') }}</div>
</a-menu-item> </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)"> <a-menu-item v-if="contextMenuTarget && selectedRange.isSingleCell()" @click="addEmptyRow(contextMenuTarget.row + 1)">
<div v-e="['a:row:insert']" class="nc-project-menu-item"> <div v-e="['a:row:insert']" class="nc-project-menu-item">
<!-- Insert New Row --> <!-- Insert New Row -->

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

@ -1,46 +1,71 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType, SelectOptionsType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import type { AppInfo } from '~/composables/useGlobal' import type { AppInfo } from '~/composables/useGlobal'
import { parseProp } from '#imports' import { parseProp } from '#imports'
export default function convertCellData( 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, isMysql = false,
isMultiple = false,
) { ) {
const { from, to, value } = args const { to, value, column } = args
if (from === to && ![UITypes.Attachment, UITypes.Date, UITypes.DateTime, UITypes.Time, UITypes.Year].includes(to)) {
return value
}
const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ' 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) { switch (to) {
case UITypes.Number: { case UITypes.Number: {
const parsedNumber = Number(value) const parsedNumber = Number(value)
if (isNaN(parsedNumber)) { 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 return parsedNumber
} }
case UITypes.Rating: { case UITypes.Rating: {
const parsedNumber = Number(value ?? 0) const parsedNumber = Number(value ?? 0)
if (isNaN(parsedNumber)) { 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 return parsedNumber
} }
case UITypes.Checkbox: 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: { case UITypes.Date: {
const parsedDate = dayjs(value) 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') return parsedDate.format('YYYY-MM-DD')
} }
case UITypes.DateTime: { case UITypes.DateTime: {
const parsedDateTime = dayjs(value) const parsedDateTime = dayjs(value)
if (!parsedDateTime.isValid()) { 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') return parsedDateTime.utc().format('YYYY-MM-DD HH:mm:ssZ')
} }
@ -54,7 +79,11 @@ export default function convertCellData(
parsedTime = dayjs(`1999-01-01 ${value}`) parsedTime = dayjs(`1999-01-01 ${value}`)
} }
if (!parsedTime.isValid()) { 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) return parsedTime.format(dateFormat)
} }
@ -69,7 +98,11 @@ export default function convertCellData(
return parsedDate.format('YYYY') 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: { case UITypes.Attachment: {
let parsedVal let parsedVal
@ -77,11 +110,17 @@ export default function convertCellData(
parsedVal = parseProp(value) parsedVal = parseProp(value)
parsedVal = Array.isArray(parsedVal) ? parsedVal : [parsedVal] parsedVal = Array.isArray(parsedVal) ? parsedVal : [parsedVal]
} catch (e) { } 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 // TODO(refactor): duplicate logic in attachment/utils.ts
const defaultAttachmentMeta = { const defaultAttachmentMeta = {
...(args.appInfo.ee && { ...(args.appInfo.ee && {
@ -95,7 +134,7 @@ export default function convertCellData(
const attachmentMeta = { const attachmentMeta = {
...defaultAttachmentMeta, ...defaultAttachmentMeta,
...parseProp(args.column?.meta), ...parseProp(column?.meta),
} }
const attachments = [] const attachments = []
@ -134,12 +173,31 @@ export default function convertCellData(
return JSON.stringify(attachments) 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.LinkToAnotherRecord:
case UITypes.Lookup: case UITypes.Lookup:
case UITypes.Rollup: case UITypes.Rollup:
case UITypes.Formula: case UITypes.Formula:
case UITypes.QrCode: case UITypes.QrCode: {
throw new Error(`Unsupported conversion from ${from} to ${to}`) if (isMultiple) {
return undefined
} else {
throw new Error(`Unsupported conversion for ${to}`)
}
}
default: default:
return value 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 dayjs from 'dayjs'
import type { MaybeRef } from '@vueuse/core' import type { MaybeRef } from '@vueuse/core'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk' 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 type { Cell } from './cellRange'
import { CellRange } from './cellRange' import { CellRange } from './cellRange'
import convertCellData from './convertCellData' import convertCellData from './convertCellData'
import type { Nullable, Row } from '~/lib' import type { Nullable, Row } from '~/lib'
import { import {
copyTable,
dateFormats, dateFormats,
extractPkFromRow, extractPkFromRow,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
isDrawerOrModalExist,
isMac, isMac,
isTypableInputColumn, isTypableInputColumn,
message, message,
@ -39,15 +40,15 @@ export function useMultiSelect(
_editEnabled: MaybeRef<boolean>, _editEnabled: MaybeRef<boolean>,
isPkAvail: MaybeRef<boolean | undefined>, isPkAvail: MaybeRef<boolean | undefined>,
clearCell: Function, clearCell: Function,
clearSelectedRangeOfCells: Function,
makeEditable: Function, makeEditable: Function,
scrollToActiveCell?: (row?: number | null, col?: number | null) => void, scrollToCell?: (row?: number | null, col?: number | null) => void,
keyEventHandler?: Function, keyEventHandler?: Function,
syncCellData?: Function, syncCellData?: Function,
bulkUpdateRows?: Function,
) { ) {
const meta = ref(_meta) const meta = ref(_meta)
const tbodyEl = ref<HTMLElement>()
const { t } = useI18n() const { t } = useI18n()
const { copy } = useCopy() const { copy } = useCopy()
@ -58,8 +59,6 @@ export function useMultiSelect(
const { isMysql } = useProject() const { isMysql } = useProject()
let clipboardContext = $ref<{ value: any; uidt: UITypes } | null>(null)
const editEnabled = ref(_editEnabled) const editEnabled = ref(_editEnabled)
let isMouseDown = $ref(false) let isMouseDown = $ref(false)
@ -100,6 +99,85 @@ export function useMultiSelect(
return parseProp(column?.meta)?.time_format ?? timeFormats[0] 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) { async function copyValue(ctx?: Cell) {
try { try {
if (selectedRange.start !== null && selectedRange.end !== null && !selectedRange.isSingleCell()) { if (selectedRange.start !== null && selectedRange.end !== null && !selectedRange.isSingleCell()) {
@ -118,51 +196,7 @@ export function useMultiSelect(
const rowObj = unref(data)[cpRow] const rowObj = unref(data)[cpRow]
const columnObj = unref(fields)[cpCol] const columnObj = unref(fields)[cpCol]
let textToCopy = (columnObj.title && rowObj.row[columnObj.title]) || '' const textToCopy = valueToCopy(rowObj, columnObj)
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')
}
}
await copy(textToCopy) await copy(textToCopy)
message.success(t('msg.info.copiedToClipboard')) message.success(t('msg.info.copiedToClipboard'))
@ -199,36 +233,65 @@ export function useMultiSelect(
function handleMouseDown(event: MouseEvent, row: number, col: number) { function handleMouseDown(event: MouseEvent, row: number, col: number) {
// if there was a right click on selected range, don't restart the selection // 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 return
} }
isMouseDown = true isMouseDown = true
// if shift key is pressed, don't restart the selection
if (event.shiftKey) return
selectedRange.startRange({ row, col }) 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) => { const handleCellClick = (event: MouseEvent, row: number, col: number) => {
isMouseDown = true 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 }) selectedRange.endRange({ row, col })
makeActive(row, col) scrollToCell?.(row, col)
scrollToActiveCell?.()
isMouseDown = false isMouseDown = false
} }
const handleMouseUp = (event: MouseEvent) => { const handleMouseUp = (event: MouseEvent) => {
// timeout is needed, because we want to set cell as active AFTER all the child's click handler's called if (isMouseDown) {
// 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 isMouseDown = false
setTimeout(() => {
makeActive(selectedRange.start.row, selectedRange.start.col) // timeout is needed, because we want to set cell as active AFTER all the child's click handler's called
}, 0) // 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 the editEnabled is false, prevent selecting text on mouseUp // if shift key is pressed, don't change the active cell
if (!unref(editEnabled)) { if (event.shiftKey) return
event.preventDefault() 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) => { const handleKeyDown = async (e: KeyboardEvent) => {
@ -237,10 +300,12 @@ export function useMultiSelect(
return true return true
} }
if (!isCellActive.value) { if (!isCellActive.value || activeCell.row === null || activeCell.col === null) {
return return
} }
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
/** on tab key press navigate through cells */ /** on tab key press navigate through cells */
switch (e.key) { switch (e.key) {
case 'Tab': case 'Tab':
@ -266,7 +331,7 @@ export function useMultiSelect(
editEnabled.value = false editEnabled.value = false
} }
} }
scrollToActiveCell?.() scrollToCell?.()
break break
/** on enter key press make cell editable */ /** on enter key press make cell editable */
case 'Enter': case 'Enter':
@ -278,49 +343,136 @@ export function useMultiSelect(
/** on delete key press clear cell */ /** on delete key press clear cell */
case 'Delete': case 'Delete':
e.preventDefault() 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 break
/** on arrow key press navigate through cells */ /** on arrow key press navigate through cells */
case 'ArrowRight': case 'ArrowRight':
e.preventDefault() e.preventDefault()
selectedRange.clear()
if (activeCell.col < unref(columnLength) - 1) { if (e.shiftKey) {
activeCell.col++ if (cmdOrCtrl) {
scrollToActiveCell?.() editEnabled.value = false
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 break
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault() e.preventDefault()
selectedRange.clear()
if (activeCell.col > 0) { if (e.shiftKey) {
activeCell.col-- if (cmdOrCtrl) {
scrollToActiveCell?.() editEnabled.value = false
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 break
case 'ArrowUp': case 'ArrowUp':
e.preventDefault() e.preventDefault()
selectedRange.clear()
if (activeCell.row > 0) { if (e.shiftKey) {
activeCell.row-- if (cmdOrCtrl) {
scrollToActiveCell?.() editEnabled.value = false
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 break
case 'ArrowDown': case 'ArrowDown':
e.preventDefault() e.preventDefault()
selectedRange.clear()
if (activeCell.row < unref(data).length - 1) { if (e.shiftKey) {
activeCell.row++ if (cmdOrCtrl) {
scrollToActiveCell?.() editEnabled.value = false
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 break
default: default:
@ -328,89 +480,21 @@ export function useMultiSelect(
const rowObj = unref(data)[activeCell.row] const rowObj = unref(data)[activeCell.row]
const columnObj = unref(fields)[activeCell.col] 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) { switch (e.keyCode) {
// copy - ctrl/cmd +c // copy - ctrl/cmd +c
case 67: 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() await copyValue()
break break
// paste - ctrl/cmd + v // select all - ctrl/cmd +a
case 86: case 65:
try { selectedRange.startRange({ row: 0, col: 0 })
// if edit permission is not there, return selectedRange.endRange({ row: unref(data).length - 1, col: unref(columnLength) - 1 })
if (!hasEditPermission) return break
// 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))
}
} }
} }
@ -438,8 +522,230 @@ 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) => {
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(document, 'keydown', handleKeyDown)
useEventListener(tbodyEl, 'mouseup', handleMouseUp) useEventListener(document, 'mouseup', handleMouseUp)
useEventListener(document, 'paste', handlePaste)
return { return {
isCellActive, isCellActive,
@ -450,7 +756,6 @@ export function useMultiSelect(
isCellSelected, isCellSelected,
activeCell, activeCell,
handleCellClick, handleCellClick,
tbodyEl,
resetSelectedRange, resetSelectedRange,
selectedRange, selectedRange,
makeActive, 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) { async function changePage(page: number) {
paginationData.value.page = page paginationData.value.page = page
await loadData({ await loadData({
@ -807,6 +925,7 @@ export function useViewData(
deleteSelectedRows, deleteSelectedRows,
deleteRangeOfRows, deleteRangeOfRows,
updateOrSaveRow, updateOrSaveRow,
bulkUpdateRows,
selectedAllRecords, selectedAllRecords,
syncCount, syncCount,
syncPagination, syncPagination,

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

@ -167,12 +167,13 @@ export class GridPage extends BasePage {
await this.dashboard.waitForLoaderToDisappear(); await this.dashboard.waitForLoaderToDisappear();
} }
async addRowRightClickMenu(index: number) { async addRowRightClickMenu(index: number, columnHeader = 'Title') {
const rowCount = await this.get().locator('.nc-grid-row').count(); 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({ const cell = await this.get().locator(`td[data-testid="cell-${columnHeader}-${index}"]`).last();
button: 'right', await cell.click();
}); await cell.click({ button: 'right' });
// Click text=Insert New Row // Click text=Insert New Row
await this.rootPage.locator('text=Insert New Row').click(); await this.rootPage.locator('text=Insert New Row').click();
await expect(await this.get().locator('.nc-grid-row')).toHaveCount(rowCount + 1); 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 { CellPageObject } from '.';
import BasePage from '../../../Base'; import BasePage from '../../../Base';
import { expect } from '@playwright/test';
export class DateCellPageObject extends BasePage { export class DateCellPageObject extends BasePage {
readonly cell: CellPageObject; 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({ async selectDate({
// date in format `YYYY-MM-DD` // date in format `YYYY-MM-DD`
date, 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 }) { async verify({ index, columnHeader, rating }: { index: number; columnHeader: string; rating: number }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded(); const cell = await this.get({ index, columnHeader });
await expect(await this.get({ index, columnHeader }).locator(`div[role="radio"][aria-checked="true"]`)).toHaveCount( await cell.scrollIntoViewIfNeeded();
rating 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 { get({ index, columnHeader }: CellProps): Locator {
if (this.parent instanceof SharedFormPage) { 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 { } 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' }, start: { index: 0, columnHeader: 'FirstName' },
end: { index: 1, columnHeader: 'LastName' }, 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(); await dashboard.closeAllTabs();
// #3 when copied with mouse, it copies correct text // #3 when copied with mouse, it copies correct text
@ -40,7 +40,7 @@ test.describe('Verify cell selection', () => {
end: { index: 1, columnHeader: 'LastName' }, end: { index: 1, columnHeader: 'LastName' },
}); });
expect(await grid.copyWithMouse({ index: 0, columnHeader: 'FirstName' })).toBe( 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(); await dashboard.closeAllTabs();
}); });

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

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