Browse Source

Merge pull request #4514 from nocodb/feat/copy-paste-cell

Feat: Grid - copy paste cell data
pull/4550/head
Raju Udava 2 years ago committed by GitHub
parent
commit
2156a75145
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 17
      packages/nc-gui/components/cell/Checkbox.vue
  2. 2
      packages/nc-gui/components/cell/MultiSelect.vue
  3. 4
      packages/nc-gui/components/cell/SingleSelect.vue
  4. 30
      packages/nc-gui/components/smartsheet/Grid.vue
  5. 2
      packages/nc-gui/composables/useColumnCreateStore.ts
  6. 83
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  7. 120
      packages/nc-gui/composables/useMultiSelect/index.ts
  8. 1
      packages/nc-gui/lang/en.json
  9. 2
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  10. 35
      tests/playwright/pages/Dashboard/common/Cell/DateCell.ts
  11. 20
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  12. 2
      tests/playwright/scripts/stressTestNewlyAddedTest.js
  13. 117
      tests/playwright/tests/gridOperations.spec.ts

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

@ -17,9 +17,9 @@ const emits = defineEmits<Emits>()
const active = inject(ActiveCellInj, ref(false))
let vModel = $computed({
let vModel = $computed<boolean>({
get: () => !!props.modelValue && props.modelValue !== '0' && props.modelValue !== 0,
set: (val) => emits('update:modelValue', val),
set: (val: boolean) => emits('update:modelValue', val),
})
const column = inject(ColumnInj)
@ -39,7 +39,13 @@ const checkboxMeta = $computed(() => {
}
})
function onClick(force?: boolean) {
function onClick(force?: boolean, event?: MouseEvent) {
if (
(event?.target as HTMLElement)?.classList?.contains('nc-checkbox') ||
(event?.target as HTMLElement)?.closest('.nc-checkbox')
) {
return
}
if (!readOnly?.value && (force || active.value)) {
vModel = !vModel
}
@ -64,16 +70,17 @@ useSelectedCellKeyupListener(active, (e) => {
'nc-cell-hover-show': !vModel && !readOnly,
'opacity-0': readOnly && !vModel,
}"
@click="onClick(false)"
@click="onClick(false, $event)"
>
<div class="px-1 pt-1 rounded-full items-center" :class="{ 'bg-gray-100': !vModel, '!ml-[-8px]': readOnly }">
<Transition name="layout" mode="out-in" :duration="100">
<component
:is="getMdiIcon(vModel ? checkboxMeta.icon.checked : checkboxMeta.icon.unchecked)"
class="nc-checkbox"
:style="{
color: checkboxMeta.color,
}"
@click.stop="onClick(true)"
@click="onClick(true)"
/>
</Transition>
</div>

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

@ -180,7 +180,7 @@ useSelectedCellKeyupListener(active, (e) => {
break
default:
// toggle only if char key pressed
if (e.key?.length === 1) {
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1) {
e.stopPropagation()
isOpen.value = true
}

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

@ -106,7 +106,7 @@ useSelectedCellKeyupListener(active, (e) => {
break
default:
// toggle only if char key pressed
if (e.key?.length === 1) {
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1) {
e.stopPropagation()
isOpen.value = true
}
@ -153,7 +153,7 @@ async function addIfMissingAndSave() {
)
vModel.value = newOptValue
await getMeta(column.value.fk_model_id!, true)
} catch (e) {
} catch (e: any) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e))
}

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

@ -169,7 +169,16 @@ const getContainerScrollForElement = (
}
const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyValue, isCellSelected, selectedCell } =
useMultiSelect(fields, data, $$(editEnabled), isPkAvail, clearCell, makeEditable, scrollToCell, (e: KeyboardEvent) => {
useMultiSelect(
meta,
fields,
data,
$$(editEnabled),
isPkAvail,
clearCell,
makeEditable,
scrollToCell,
(e: KeyboardEvent) => {
// ignore navigating if picker(Date, Time, DateTime, Year)
// or single/multi select options is open
const activePickerOrDropdownEl = document.querySelector(
@ -261,7 +270,19 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
}
}
}
})
},
async (ctx: { row: number; col?: number; updatedColumnTitle?: string }) => {
const rowObj = data.value[ctx.row]
const columnObj = ctx.col !== null && ctx.col !== undefined ? fields.value[ctx.col] : null
if (!ctx.updatedColumnTitle && isVirtualCol(columnObj)) {
return
}
// update/save cell value
await updateOrSaveRow(rowObj, ctx.updatedColumnTitle || columnObj.title)
},
)
function scrollToCell(row?: number | null, col?: number | null) {
row = row ?? selectedCell.row
@ -369,7 +390,7 @@ watch(contextMenu, () => {
const rowRefs = $ref<any[]>()
async function clearCell(ctx: { row: number; col: number } | null) {
async function clearCell(ctx: { row: number; col: number } | null, skipUpdate = false) {
if (
!ctx ||
!hasEditPermission ||
@ -386,9 +407,12 @@ async function clearCell(ctx: { row: number; col: number } | null) {
}
rowObj.row[columnObj.title] = null
if (!skipUpdate) {
// update/save cell value
await updateOrSaveRow(rowObj, columnObj.title)
}
}
function makeEditable(row: Row, col: ColumnType) {
if (!hasEditPermission || editEnabled || isView) {

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

@ -194,7 +194,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const addOrUpdate = async (onSuccess: () => void, columnPosition?: Pick<ColumnReqType, 'column_order'>) => {
try {
if (!(await validate())) return
} catch (e) {
} catch (e: any) {
const errorMsgs = e.errorFields
?.map((e: any) => e.errors?.join(', '))
.filter(Boolean)

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

@ -0,0 +1,83 @@
import dayjs from 'dayjs'
import { UITypes } from 'nocodb-sdk'
export default function convertCellData(args: { from: UITypes; to: UITypes; value: any }, isMysql = false) {
const { from, to, value } = 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'
switch (to) {
case UITypes.Number: {
const parsedNumber = Number(value)
if (isNaN(parsedNumber)) {
throw new TypeError(`Cannot convert '${value}' to number`)
}
return parsedNumber
}
case UITypes.Checkbox:
return Boolean(value)
case UITypes.Date: {
const parsedDate = dayjs(value)
if (!parsedDate.isValid()) 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')
}
return parsedDateTime.format(dateFormat)
}
case UITypes.Time: {
let parsedTime = dayjs(value)
if (!parsedTime.isValid()) {
parsedTime = dayjs(value, 'HH:mm:ss')
}
if (!parsedTime.isValid()) {
parsedTime = dayjs(`1999-01-01 ${value}`)
}
if (!parsedTime.isValid()) {
throw new Error('Not a valid time value')
}
return parsedTime.format(dateFormat)
}
case UITypes.Year: {
if (/^\d+$/.test(value)) {
return +value
}
const parsedDate = dayjs(value)
if (parsedDate.isValid()) {
return parsedDate.format('YYYY')
}
throw new Error('Not a valid year value')
}
case UITypes.Attachment: {
let parsedVal
try {
parsedVal = typeof value === 'string' ? JSON.parse(value) : value
parsedVal = Array.isArray(parsedVal) ? parsedVal : [parsedVal]
} catch (e) {
throw new Error('Invalid attachment data')
}
if (parsedVal.some((v: any) => v && !(v.url || v.data))) {
throw new Error('Invalid attachment data')
}
return JSON.stringify(parsedVal)
}
case UITypes.LinkToAnotherRecord:
case UITypes.Lookup:
case UITypes.Rollup:
case UITypes.Formula:
case UITypes.QrCode:
throw new Error(`Unsupported conversion from ${from} to ${to}`)
default:
return value
}
}

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

@ -1,14 +1,31 @@
import type { MaybeRef } from '@vueuse/core'
import type { ColumnType } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import type { Cell } from './cellRange'
import { CellRange } from './cellRange'
import { copyTable, message, reactive, ref, unref, useCopy, useEventListener, useI18n } from '#imports'
import convertCellData from './convertCellData'
import type { Row } from '~/lib'
import {
copyTable,
extractPkFromRow,
extractSdkResponseErrorMsg,
isMac,
message,
reactive,
ref,
unref,
useCopy,
useEventListener,
useI18n,
useMetas,
useProject,
} from '#imports'
/**
* Utility to help with multi-selecting rows/cells in the smartsheet
*/
export function useMultiSelect(
_meta: MaybeRef<TableType>,
fields: MaybeRef<ColumnType[]>,
data: MaybeRef<Row[]>,
_editEnabled: MaybeRef<boolean>,
@ -17,11 +34,20 @@ export function useMultiSelect(
makeEditable: Function,
scrollToActiveCell?: (row?: number | null, col?: number | null) => void,
keyEventHandler?: Function,
syncCellData?: Function,
) {
const meta = ref(_meta)
const { t } = useI18n()
const { copy } = useCopy()
const { getMeta } = useMetas()
const { isMysql } = useProject()
let clipboardContext = $ref<{ value: any; uidt: UITypes } | null>(null)
const editEnabled = ref(_editEnabled)
const selectedCell = reactive<Cell>({ row: null, col: null })
@ -49,6 +75,11 @@ export function useMultiSelect(
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)
}
@ -217,12 +248,95 @@ export function useMultiSelect(
const columnObj = unref(fields)[selectedCell.col]
if ((!unref(editEnabled) && e.metaKey) || e.ctrlKey) {
if (
(!unref(editEnabled) ||
[
UITypes.DateTime,
UITypes.Date,
UITypes.Year,
UITypes.Time,
UITypes.Lookup,
UITypes.Rollup,
UITypes.Formula,
UITypes.Attachment,
UITypes.Checkbox,
UITypes.Rating,
].includes(columnObj.uidt as UITypes)) &&
(isMac() ? e.metaKey : e.ctrlKey)
) {
switch (e.keyCode) {
// copy - ctrl/cmd +c
case 67:
// set clipboard context only if single cell selected
if (rowObj.row[columnObj.title!]) {
clipboardContext = {
value: rowObj.row[columnObj.title!],
uidt: columnObj.uidt as UITypes,
}
} else {
clipboardContext = null
}
await copyValue()
break
case 86:
try {
// 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,
},
isMysql.value,
)
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?.({ ...selectedCell, 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,
},
isMysql.value,
)
e.preventDefault()
syncCellData?.(selectedCell)
} else {
clearCell(selectedCell as { row: number; col: number }, true)
makeEditable(rowObj, columnObj)
}
} catch (error: any) {
message.error(await extractSdkResponseErrorMsg(error))
}
}
}

1
packages/nc-gui/lang/en.json

@ -497,6 +497,7 @@
}
},
"info": {
"pasteNotSupported": "Paste operation is not supported on the active cell",
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
"orgViewer": "Viewer is not allowed to create new projects but they can access any invited project."

2
tests/playwright/pages/Dashboard/Grid/Column/index.ts

@ -155,7 +155,7 @@ export class ColumnPageObject extends BasePage {
await this.get().locator('.ant-select-selection-search-input[aria-expanded="true"]').fill(type);
// Select column type
await this.rootPage.locator(`text=${type}`).nth(1).click();
await this.rootPage.locator('.rc-virtual-list-holder-inner > div').locator(`text="${type}"`).click();
}
async changeReferencedColumnForQrCode({ titleOfReferencedColumn }: { titleOfReferencedColumn: string }) {

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

@ -0,0 +1,35 @@
import { CellPageObject } from '.';
import BasePage from '../../../Base';
export class DateCellPageObject extends BasePage {
readonly cell: CellPageObject;
constructor(cell: CellPageObject) {
super(cell.rootPage);
this.cell = cell;
}
get({ index, columnHeader }: { index?: number; columnHeader: string }) {
return this.cell.get({ index, columnHeader });
}
async open({ index, columnHeader }: { index: number; columnHeader: string }) {
await this.cell.dblclick({
index,
columnHeader,
});
}
async selectDate({
// date in format `YYYY-MM-DD`
date,
}: {
date: string;
}) {
await this.rootPage.locator(`td[title="${date}"]`).click();
}
async close() {
await this.rootPage.keyboard.press('Escape');
}
}

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

@ -6,6 +6,7 @@ import { SelectOptionCellPageObject } from './SelectOptionCell';
import { SharedFormPage } from '../../../SharedForm';
import { CheckboxCellPageObject } from './CheckboxCell';
import { RatingCellPageObject } from './RatingCell';
import { DateCellPageObject } from './DateCell';
export class CellPageObject extends BasePage {
readonly parent: GridPage | SharedFormPage;
@ -13,6 +14,8 @@ export class CellPageObject extends BasePage {
readonly attachment: AttachmentCellPageObject;
readonly checkbox: CheckboxCellPageObject;
readonly rating: RatingCellPageObject;
readonly date: DateCellPageObject;
constructor(parent: GridPage | SharedFormPage) {
super(parent.rootPage);
this.parent = parent;
@ -20,6 +23,7 @@ export class CellPageObject extends BasePage {
this.attachment = new AttachmentCellPageObject(this);
this.checkbox = new CheckboxCellPageObject(this);
this.rating = new RatingCellPageObject(this);
this.date = new DateCellPageObject(this);
}
get({ index, columnHeader }: { index?: number; columnHeader: string }): Locator {
@ -30,8 +34,11 @@ export class CellPageObject extends BasePage {
}
}
async click({ index, columnHeader }: { index: number; columnHeader: string }) {
await this.get({ index, columnHeader }).click();
async click(
{ index, columnHeader }: { index: number; columnHeader: string },
...options: Parameters<Locator['click']>
) {
await this.get({ index, columnHeader }).click(...options);
await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable');
}
@ -179,4 +186,13 @@ export class CellPageObject extends BasePage {
param.role === 'creator' || param.role === 'editor' ? 1 : 0
);
}
async copyToClipboard(
{ index, columnHeader }: { index: number; columnHeader: string },
...clickOptions: Parameters<Locator['click']>
) {
await this.get({ index, columnHeader }).click(...clickOptions);
await this.get({ index, columnHeader }).press('Control+C');
}
}

2
tests/playwright/scripts/stressTestNewlyAddedTest.js

@ -13,7 +13,7 @@ void (async () => {
return;
}
const { stdout } = await exec(`git diff origin/develop -- *.spec.ts **/*.spec.ts | grep test\\(`);
const { stdout } = await exec(`git diff origin/develop -- **/*.spec.ts | grep test\\( | cat`);
// eslint-disable-next-line no-undef
const dbType = process.env.E2E_DB_TYPE;

117
tests/playwright/tests/gridOperations.spec.ts

@ -0,0 +1,117 @@
import { expect, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import setup from '../setup';
test.describe('Grid operations', () => {
let dashboard: DashboardPage;
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
});
test('Clipboard support', async () => {
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.createTable({ title: 'Sheet1' });
await dashboard.grid.column.create({
title: 'Number',
type: 'Number',
});
await dashboard.grid.column.create({
title: 'Checkbox',
type: 'Checkbox',
});
await dashboard.grid.column.create({
title: 'Date',
type: 'Date',
});
await dashboard.grid.column.create({
title: 'Attachment',
type: 'Attachment',
});
await dashboard.grid.addNewRow({
index: 0,
});
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'Number',
});
await dashboard.grid.cell.fillText({
index: 0,
columnHeader: 'Number',
text: '123',
});
await dashboard.grid.cell.click({
index: 0,
columnHeader: 'Checkbox',
});
const today = new Date().toISOString().slice(0, 10);
await dashboard.grid.cell.date.open({
index: 0,
columnHeader: 'Date',
});
await dashboard.grid.cell.date.selectDate({
date: today,
});
await dashboard.grid.cell.date.close();
await dashboard.grid.cell.attachment.addFile({
index: 0,
columnHeader: 'Attachment',
filePath: `${process.cwd()}/fixtures/sampleFiles/1.json`,
});
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'Title',
});
expect(await dashboard.grid.cell.getClipboardText()).toBe('Row 0');
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'Number',
});
expect(await dashboard.grid.cell.getClipboardText()).toBe('123');
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({
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');
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'Date',
});
expect(await dashboard.grid.cell.getClipboardText()).toBe(today);
await dashboard.grid.cell.copyToClipboard({
index: 0,
columnHeader: 'Attachment',
});
// expect(await dashboard.grid.cell.getClipboardText()).toBe('1.json');
});
});
Loading…
Cancel
Save