Browse Source

Merge branch 'develop' into fix/formula-empty-result

pull/4644/head
Wing-Kam Wong 2 years ago
parent
commit
70cf6c3c6f
  1. 4
      .github/ISSUE_TEMPLATE/--bug-report.yaml
  2. 3
      .github/workflows/uffizzi-preview.yml
  3. 1
      packages/nc-gui/components/smartsheet/Cell.vue
  4. 76
      packages/nc-gui/components/smartsheet/Grid.vue
  5. 5
      packages/nc-gui/components/smartsheet/toolbar/SearchData.vue
  6. 21
      packages/nc-gui/composables/useFieldQuery.ts
  7. 28
      packages/nc-gui/composables/useMultiSelect/cellRange.ts
  8. 167
      packages/nc-gui/composables/useMultiSelect/index.ts
  9. 2
      packages/nc-gui/composables/useSmartsheetStore.ts
  10. 2
      packages/nc-gui/lib/types.ts
  11. 32
      tests/playwright/pages/Dashboard/Grid/index.ts
  12. 41
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  13. 20
      tests/playwright/pages/Dashboard/common/Toolbar/SearchData.ts
  14. 3
      tests/playwright/pages/Dashboard/common/Toolbar/index.ts
  15. 109
      tests/playwright/tests/cellSelection.spec.ts
  16. 49
      tests/playwright/tests/views.spec.ts

4
.github/ISSUE_TEMPLATE/--bug-report.yaml

@ -35,7 +35,7 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: Project Details label: Project Details
description: Where to find it ? (See [YouTube video](https://www.youtube.com/watch?v=AUSNN-RCwhE) or [Docs](https://docs.nocodb.com/FAQs#how-to-check-my-project-info-)) description: Click on top left icon and click `Copy Project Info`. (See [YouTube video](https://www.youtube.com/watch?v=AUSNN-RCwhE) or [Docs](https://docs.nocodb.com/FAQs#how-to-check-my-project-info-))
placeholder: | placeholder: |
or provide the following info or provide the following info
``` ```
@ -58,4 +58,4 @@ body:
placeholder: | placeholder: |
> Drag & drop relevant image or videos > Drag & drop relevant image or videos
validations: validations:
required: false required: false

3
.github/workflows/uffizzi-preview.yml

@ -11,6 +11,7 @@ jobs:
cache-compose-file: cache-compose-file:
name: Cache Compose File name: Cache Compose File
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
outputs: outputs:
compose-file-cache-key: ${{ env.COMPOSE_FILE_HASH }} compose-file-cache-key: ${{ env.COMPOSE_FILE_HASH }}
pr-number: ${{ env.PR_NUMBER }} pr-number: ${{ env.PR_NUMBER }}
@ -85,4 +86,4 @@ jobs:
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
id-token: write id-token: write

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

@ -163,6 +163,7 @@ const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
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 @click.stop.prevent
@dblclick.stop.prevent
/> />
</template> </template>
</div> </div>

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

@ -172,7 +172,7 @@ const getContainerScrollForElement = (
return scroll return scroll
} }
const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyValue, isCellSelected, selectedCell } = const { isCellSelected, activeCell, handleMouseDown, handleMouseOver, handleCellClick, clearSelectedRange, copyValue } =
useMultiSelect( useMultiSelect(
meta, meta,
fields, fields,
@ -201,9 +201,10 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
const altOrOptionKey = e.altKey const altOrOptionKey = e.altKey
if (e.key === ' ') { if (e.key === ' ') {
if (selectedCell.row !== null && !editEnabled) { if (activeCell.row != null && !editEnabled) {
e.preventDefault() e.preventDefault()
const row = data.value[selectedCell.row] clearSelectedRange()
const row = data.value[activeCell.row]
expandForm(row) expandForm(row)
return true return true
} }
@ -227,29 +228,33 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
switch (e.key) { switch (e.key) {
case 'ArrowUp': case 'ArrowUp':
e.preventDefault() e.preventDefault()
selectedCell.row = 0 clearSelectedRange()
selectedCell.col = selectedCell.col ?? 0 activeCell.row = 0
activeCell.col = activeCell.col ?? 0
scrollToCell?.() scrollToCell?.()
editEnabled = false editEnabled = false
return true return true
case 'ArrowDown': case 'ArrowDown':
e.preventDefault() e.preventDefault()
selectedCell.row = data.value.length - 1 clearSelectedRange()
selectedCell.col = selectedCell.col ?? 0 activeCell.row = data.value.length - 1
activeCell.col = activeCell.col ?? 0
scrollToCell?.() scrollToCell?.()
editEnabled = false editEnabled = false
return true return true
case 'ArrowRight': case 'ArrowRight':
e.preventDefault() e.preventDefault()
selectedCell.row = selectedCell.row ?? 0 clearSelectedRange()
selectedCell.col = fields.value?.length - 1 activeCell.row = activeCell.row ?? 0
activeCell.col = fields.value?.length - 1
scrollToCell?.() scrollToCell?.()
editEnabled = false editEnabled = false
return true return true
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault() e.preventDefault()
selectedCell.row = selectedCell.row ?? 0 clearSelectedRange()
selectedCell.col = 0 activeCell.row = activeCell.row ?? 0
activeCell.col = 0
scrollToCell?.() scrollToCell?.()
editEnabled = false editEnabled = false
return true return true
@ -279,7 +284,7 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
}, },
async (ctx: { row: number; col?: number; updatedColumnTitle?: string }) => { async (ctx: { row: number; col?: number; updatedColumnTitle?: string }) => {
const rowObj = data.value[ctx.row] const rowObj = data.value[ctx.row]
const columnObj = ctx.col !== null && ctx.col !== undefined ? fields.value[ctx.col] : null const columnObj = ctx.col !== undefined ? fields.value[ctx.col] : null
if (!ctx.updatedColumnTitle && isVirtualCol(columnObj)) { if (!ctx.updatedColumnTitle && isVirtualCol(columnObj)) {
return return
@ -291,10 +296,10 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
) )
function scrollToCell(row?: number | null, col?: number | null) { function scrollToCell(row?: number | null, col?: number | null) {
row = row ?? selectedCell.row row = row ?? activeCell.row
col = col ?? selectedCell.col col = col ?? activeCell.col
if (row !== undefined && col !== undefined && row !== null && col !== null) { if (row !== null && col !== null) {
// get active cell // get active cell
const rows = tbodyEl.value?.querySelectorAll('tr') const rows = tbodyEl.value?.querySelectorAll('tr')
const cols = rows?.[row].querySelectorAll('td') const cols = rows?.[row].querySelectorAll('td')
@ -455,13 +460,14 @@ 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(smartTable, (e) => { onClickOutside(smartTable, (e) => {
// do nothing if context menu was open // do nothing if context menu was open
if (contextMenu.value) return if (contextMenu.value) return
clearSelectedRange()
if (selectedCell.col === null) return
const activeCol = fields.value[selectedCell.col] if (activeCell.row === null || activeCell.col === null) return
const activeCol = fields.value[activeCell.col]
if (editEnabled && (isVirtualCol(activeCol) || activeCol.uidt === UITypes.JSON)) return if (editEnabled && (isVirtualCol(activeCol) || activeCol.uidt === UITypes.JSON)) return
@ -482,25 +488,29 @@ onClickOutside(smartTable, (e) => {
return return
} }
selectedCell.row = null clearSelectedRange()
selectedCell.col = null activeCell.row = null
activeCell.col = null
}) })
const onNavigate = (dir: NavigateDir) => { const onNavigate = (dir: NavigateDir) => {
if (selectedCell.row === null || selectedCell.col === null) return if (activeCell.row === null || activeCell.col === null) return
editEnabled = false editEnabled = false
clearSelectedRange()
switch (dir) { switch (dir) {
case NavigateDir.NEXT: case NavigateDir.NEXT:
if (selectedCell.row < data.value.length - 1) { if (activeCell.row < data.value.length - 1) {
selectedCell.row++ activeCell.row++
} else { } else {
addEmptyRow() addEmptyRow()
selectedCell.row++ activeCell.row++
} }
break break
case NavigateDir.PREV: case NavigateDir.PREV:
if (selectedCell.row > 0) { if (activeCell.row > 0) {
selectedCell.row-- activeCell.row--
} }
break break
} }
@ -782,10 +792,10 @@ const closeAddColumnDropdown = () => {
:data-key="rowIndex + columnObj.id" :data-key="rowIndex + columnObj.id"
:data-col="columnObj.id" :data-col="columnObj.id"
:data-title="columnObj.title" :data-title="columnObj.title"
@click="selectCell(rowIndex, colIndex)" @mousedown="handleMouseDown($event, rowIndex, colIndex)"
@mouseover="handleMouseOver(rowIndex, colIndex)"
@click="handleCellClick($event, rowIndex, colIndex)"
@dblclick="makeEditable(row, columnObj)" @dblclick="makeEditable(row, columnObj)"
@mousedown="startSelectRange($event, rowIndex, colIndex)"
@mouseover="endSelectRange(rowIndex, colIndex)"
@contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })" @contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })"
> >
<div v-if="!switchingTab" class="w-full h-full"> <div v-if="!switchingTab" class="w-full h-full">
@ -793,7 +803,7 @@ const closeAddColumnDropdown = () => {
v-if="isVirtualCol(columnObj)" v-if="isVirtualCol(columnObj)"
v-model="row.row[columnObj.title]" v-model="row.row[columnObj.title]"
:column="columnObj" :column="columnObj"
:active="selectedCell.col === colIndex && selectedCell.row === rowIndex" :active="activeCell.col === colIndex && activeCell.row === rowIndex"
:row="row" :row="row"
@navigate="onNavigate" @navigate="onNavigate"
/> />
@ -803,10 +813,10 @@ const closeAddColumnDropdown = () => {
v-model="row.row[columnObj.title]" v-model="row.row[columnObj.title]"
:column="columnObj" :column="columnObj"
:edit-enabled=" :edit-enabled="
!!hasEditPermission && !!editEnabled && selectedCell.col === colIndex && selectedCell.row === rowIndex !!hasEditPermission && !!editEnabled && activeCell.col === colIndex && activeCell.row === rowIndex
" "
:row-index="rowIndex" :row-index="rowIndex"
:active="selectedCell.col === colIndex && selectedCell.row === rowIndex" :active="activeCell.col === colIndex && activeCell.row === rowIndex"
@update:edit-enabled="editEnabled = $event" @update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow(row, columnObj.title, state)" @save="updateOrSaveRow(row, columnObj.title, state)"
@navigate="onNavigate" @navigate="onNavigate"
@ -872,7 +882,7 @@ const closeAddColumnDropdown = () => {
</div> </div>
</a-menu-item> </a-menu-item>
<a-menu-item v-if="contextMenuTarget" @click="copyValue(contextMenuTarget)"> <a-menu-item v-if="contextMenuTarget" data-testid="context-menu-item-copy" @click="copyValue(contextMenuTarget)">
<div v-e="['a:row:copy']" class="nc-project-menu-item"> <div v-e="['a:row:copy']" class="nc-project-menu-item">
<!-- Copy --> <!-- Copy -->
{{ $t('general.copy') }} {{ $t('general.copy') }}

5
packages/nc-gui/components/smartsheet/toolbar/SearchData.vue

@ -17,7 +17,7 @@ const { meta } = useSmartsheetStoreOrThrow()
const activeView = inject(ActiveViewInj, ref()) const activeView = inject(ActiveViewInj, ref())
const { search, loadFieldQuery } = useFieldQuery(activeView) const { search, loadFieldQuery } = useFieldQuery()
const isDropdownOpen = ref(false) const isDropdownOpen = ref(false)
@ -36,7 +36,7 @@ watch(
() => activeView.value?.id, () => activeView.value?.id,
(n, o) => { (n, o) => {
if (n !== o) { if (n !== o) {
loadFieldQuery(activeView) loadFieldQuery(activeView.value?.id)
} }
}, },
{ immediate: true }, { immediate: true },
@ -76,6 +76,7 @@ function onPressEnter() {
class="max-w-[200px]" class="max-w-[200px]"
:placeholder="$t('placeholder.filterQuery')" :placeholder="$t('placeholder.filterQuery')"
:bordered="false" :bordered="false"
data-testid="search-data-input"
@press-enter="onPressEnter" @press-enter="onPressEnter"
> >
<template #addonBefore> </template> <template #addonBefore> </template>

21
packages/nc-gui/composables/useFieldQuery.ts

@ -1,8 +1,6 @@
import type { Ref } from 'vue'
import type { ViewType } from 'nocodb-sdk'
import { useState } from '#imports' import { useState } from '#imports'
export function useFieldQuery(view: Ref<ViewType | undefined>) { export function useFieldQuery() {
// initial search object // initial search object
const emptyFieldQueryObj = { const emptyFieldQueryObj = {
field: '', field: '',
@ -13,21 +11,16 @@ export function useFieldQuery(view: Ref<ViewType | undefined>) {
const searchMap = useState<Record<string, { field: string; query: string }>>('field-query-search-map', () => ({})) const searchMap = useState<Record<string, { field: string; query: string }>>('field-query-search-map', () => ({}))
// the fieldQueryObj under the current view // the fieldQueryObj under the current view
const search = useState<{ field: string; query: string }>('field-query-search', () => emptyFieldQueryObj) const search = useState<{ field: string; query: string }>('field-query-search', () => ({ ...emptyFieldQueryObj }))
// map current view id to emptyFieldQueryObj
if (view?.value?.id) {
searchMap.value[view!.value!.id] = search.value
}
// retrieve the fieldQueryObj of the given view id // retrieve the fieldQueryObj of the given view id
// if it is not found in `searchMap`, init with emptyFieldQueryObj // if it is not found in `searchMap`, init with emptyFieldQueryObj
const loadFieldQuery = (view: Ref<ViewType | undefined>) => { const loadFieldQuery = (id?: string) => {
if (!view.value?.id) return if (!id) return
if (!(view!.value!.id in searchMap.value)) { if (!(id in searchMap.value)) {
searchMap.value[view!.value!.id!] = emptyFieldQueryObj searchMap.value[id] = { ...emptyFieldQueryObj }
} }
search.value = searchMap.value[view!.value!.id!] search.value = searchMap.value[id]
} }
return { search, loadFieldQuery } return { search, loadFieldQuery }

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

@ -1,6 +1,6 @@
export interface Cell { export interface Cell {
row: number | null row: number
col: number | null col: number
} }
export class CellRange { export class CellRange {
@ -12,14 +12,22 @@ export class CellRange {
this._end = end ?? this._start this._end = end ?? this._start
} }
get start() { isEmpty() {
return this._start == null || this._end == null
}
isSingleCell() {
return !this.isEmpty() && this._start?.col === this._end?.col && this._start?.row === this._end?.row
}
get start(): Cell {
return { return {
row: Math.min(this._start?.row ?? NaN, this._end?.row ?? NaN), row: Math.min(this._start?.row ?? NaN, this._end?.row ?? NaN),
col: Math.min(this._start?.col ?? NaN, this._end?.col ?? NaN), col: Math.min(this._start?.col ?? NaN, this._end?.col ?? NaN),
} }
} }
get end() { get end(): Cell {
return { return {
row: Math.max(this._start?.row ?? NaN, this._end?.row ?? NaN), row: Math.max(this._start?.row ?? NaN, this._end?.row ?? NaN),
col: Math.max(this._start?.col ?? NaN, this._end?.col ?? NaN), col: Math.max(this._start?.col ?? NaN, this._end?.col ?? NaN),
@ -27,19 +35,11 @@ export class CellRange {
} }
startRange(value: Cell) { startRange(value: Cell) {
if (value == null) {
return
}
this._start = value this._start = value
this._end = value this._end = value
} }
endRange(value: Cell) { endRange(value: Cell) {
if (value == null) {
return
}
this._end = value this._end = value
} }
@ -47,8 +47,4 @@ export class CellRange {
this._start = null this._start = null
this._end = null this._end = null
} }
isEmpty() {
return this._start == null || this._end == null
}
} }

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

@ -4,7 +4,7 @@ import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
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 { Row } from '~/lib' import type { Nullable, Row } from '~/lib'
import { import {
copyTable, copyTable,
extractPkFromRow, extractPkFromRow,
@ -22,11 +22,13 @@ import {
useProject, useProject,
} from '#imports' } from '#imports'
const MAIN_MOUSE_PRESSED = 0
/** /**
* Utility to help with multi-selecting rows/cells in the smartsheet * Utility to help with multi-selecting rows/cells in the smartsheet
*/ */
export function useMultiSelect( export function useMultiSelect(
_meta: MaybeRef<TableType>, _meta: MaybeRef<TableType | undefined>,
fields: MaybeRef<ColumnType[]>, fields: MaybeRef<ColumnType[]>,
data: MaybeRef<Row[]>, data: MaybeRef<Row[]>,
_editEnabled: MaybeRef<boolean>, _editEnabled: MaybeRef<boolean>,
@ -51,15 +53,26 @@ export function useMultiSelect(
const editEnabled = ref(_editEnabled) const editEnabled = ref(_editEnabled)
const selectedCell = reactive<Cell>({ row: null, col: null })
const selectedRange = reactive(new CellRange())
let isMouseDown = $ref(false) let isMouseDown = $ref(false)
const selectedRange = reactive(new CellRange())
const activeCell = reactive<Nullable<Cell>>({ row: null, col: null })
const columnLength = $computed(() => unref(fields)?.length) const columnLength = $computed(() => unref(fields)?.length)
function makeActive(row: number, col: number) {
if (activeCell.row === row && activeCell.col === col) {
return
}
activeCell.row = row
activeCell.col = col
}
async function copyValue(ctx?: Cell) { async function copyValue(ctx?: Cell) {
try { try {
if (!selectedRange.isEmpty()) { if (selectedRange.start !== null && selectedRange.end !== null && !selectedRange.isSingleCell()) {
const cprows = unref(data).slice(selectedRange.start.row, selectedRange.end.row + 1) // slice the selected rows for copy const cprows = unref(data).slice(selectedRange.start.row, selectedRange.end.row + 1) // slice the selected rows for copy
const cpcols = unref(fields).slice(selectedRange.start.col, selectedRange.end.col + 1) // slice the selected cols for copy const cpcols = unref(fields).slice(selectedRange.start.col, selectedRange.end.col + 1) // slice the selected cols for copy
@ -68,8 +81,8 @@ export function useMultiSelect(
} else { } else {
// if copy was called with context (right click position) - copy value from context // if copy was called with context (right click position) - copy value from context
// else if there is just one selected cell, copy it's value // else if there is just one selected cell, copy it's value
const cpRow = ctx?.row ?? selectedCell?.row const cpRow = ctx?.row ?? activeCell.row
const cpCol = ctx?.col ?? selectedCell?.col const cpCol = ctx?.col ?? activeCell.col
if (cpRow != null && cpCol != null) { if (cpRow != null && cpCol != null) {
const rowObj = unref(data)[cpRow] const rowObj = unref(data)[cpRow]
@ -93,29 +106,19 @@ export function useMultiSelect(
} }
} }
function selectCell(row: number, col: number) { function handleMouseOver(row: number, col: number) {
selectedRange.clear()
if (selectedCell.row === row && selectedCell.col === col) return
editEnabled.value = false
selectedCell.row = row
selectedCell.col = col
}
function endSelectRange(row: number, col: number) {
if (!isMouseDown) { if (!isMouseDown) {
return return
} }
selectedCell.row = null
selectedCell.col = null
selectedRange.endRange({ row, col }) selectedRange.endRange({ row, col })
} }
function isCellSelected(row: number, col: number) { function isCellSelected(row: number, col: number) {
if (selectedCell?.row === row && selectedCell?.col === col) { if (activeCell.col === col && activeCell.row === row) {
return true return true
} }
if (selectedRange.isEmpty()) { if (selectedRange.start === null || selectedRange.end === null) {
return false return false
} }
@ -127,46 +130,51 @@ export function useMultiSelect(
) )
} }
function startSelectRange(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
const leftClickButton = 0 if (event?.button !== MAIN_MOUSE_PRESSED && isCellSelected(row, col)) {
if (event?.button !== leftClickButton && isCellSelected(row, col)) {
return return
} }
if (unref(editEnabled)) { editEnabled.value = false
event.preventDefault() isMouseDown = true
return selectedRange.startRange({ row, col })
} }
const handleCellClick = (event: MouseEvent, row: number, col: number) => {
isMouseDown = true isMouseDown = true
selectedRange.clear() editEnabled.value = false
selectedRange.startRange({ row, col }) selectedRange.startRange({ row, col })
selectedRange.endRange({ row, col })
makeActive(row, col)
isMouseDown = false
} }
useEventListener(document, 'mouseup', (e) => { const handleMouseUp = (event: MouseEvent) => {
// if the editEnabled is false prevent the mouseup event for not select text // 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)) { if (!unref(editEnabled)) {
e.preventDefault() event.preventDefault()
} }
isMouseDown = false isMouseDown = false
}) }
const onKeyDown = async (e: KeyboardEvent) => { const handleKeyDown = async (e: KeyboardEvent) => {
// invoke the keyEventHandler if provided and return if it returns true // invoke the keyEventHandler if provided and return if it returns true
if (await keyEventHandler?.(e)) { if (await keyEventHandler?.(e)) {
return true return true
} }
if (!selectedRange.isEmpty()) { if (activeCell.row === null || activeCell.col === null) {
// In case the user press tabs or arrows keys return
selectedCell.row = selectedRange.start.row
selectedCell.col = selectedRange.start.col
} }
if (selectedCell.row === null || selectedCell.col === null) return
/** on tab key press navigate through cells */ /** on tab key press navigate through cells */
switch (e.key) { switch (e.key) {
case 'Tab': case 'Tab':
@ -174,21 +182,21 @@ export function useMultiSelect(
selectedRange.clear() selectedRange.clear()
if (e.shiftKey) { if (e.shiftKey) {
if (selectedCell.col > 0) { if (activeCell.col > 0) {
selectedCell.col-- activeCell.col--
editEnabled.value = false editEnabled.value = false
} else if (selectedCell.row > 0) { } else if (activeCell.row > 0) {
selectedCell.row-- activeCell.row--
selectedCell.col = unref(columnLength) - 1 activeCell.col = unref(columnLength) - 1
editEnabled.value = false editEnabled.value = false
} }
} else { } else {
if (selectedCell.col < unref(columnLength) - 1) { if (activeCell.col < unref(columnLength) - 1) {
selectedCell.col++ activeCell.col++
editEnabled.value = false editEnabled.value = false
} else if (selectedCell.row < unref(data).length - 1) { } else if (activeCell.row < unref(data).length - 1) {
selectedCell.row++ activeCell.row++
selectedCell.col = 0 activeCell.col = 0
editEnabled.value = false editEnabled.value = false
} }
} }
@ -198,63 +206,68 @@ export function useMultiSelect(
case 'Enter': case 'Enter':
e.preventDefault() e.preventDefault()
selectedRange.clear() selectedRange.clear()
makeEditable(unref(data)[selectedCell.row], unref(fields)[selectedCell.col])
makeEditable(unref(data)[activeCell.row], unref(fields)[activeCell.col])
break break
/** on delete key press clear cell */ /** on delete key press clear cell */
case 'Delete': case 'Delete':
e.preventDefault() e.preventDefault()
selectedRange.clear() selectedRange.clear()
await clearCell(selectedCell as { row: number; col: number })
await clearCell(activeCell as { row: number; col: number })
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() selectedRange.clear()
if (selectedCell.col < unref(columnLength) - 1) {
selectedCell.col++ if (activeCell.col < unref(columnLength) - 1) {
activeCell.col++
scrollToActiveCell?.() scrollToActiveCell?.()
editEnabled.value = false editEnabled.value = false
} }
break break
case 'ArrowLeft': case 'ArrowLeft':
selectedRange.clear()
e.preventDefault() e.preventDefault()
if (selectedCell.col > 0) { selectedRange.clear()
selectedCell.col--
if (activeCell.col > 0) {
activeCell.col--
scrollToActiveCell?.() scrollToActiveCell?.()
editEnabled.value = false editEnabled.value = false
} }
break break
case 'ArrowUp': case 'ArrowUp':
selectedRange.clear()
e.preventDefault() e.preventDefault()
if (selectedCell.row > 0) { selectedRange.clear()
selectedCell.row--
if (activeCell.row > 0) {
activeCell.row--
scrollToActiveCell?.() scrollToActiveCell?.()
editEnabled.value = false editEnabled.value = false
} }
break break
case 'ArrowDown': case 'ArrowDown':
selectedRange.clear()
e.preventDefault() e.preventDefault()
if (selectedCell.row < unref(data).length - 1) { selectedRange.clear()
selectedCell.row++
if (activeCell.row < unref(data).length - 1) {
activeCell.row++
scrollToActiveCell?.() scrollToActiveCell?.()
editEnabled.value = false editEnabled.value = false
} }
break break
default: default:
{ {
const rowObj = unref(data)[selectedCell.row] const rowObj = unref(data)[activeCell.row]
const columnObj = unref(fields)[activeCell.col]
const columnObj = unref(fields)[selectedCell.col]
if ((!unref(editEnabled) || !isTypableInputColumn(columnObj)) && (isMac() ? e.metaKey : e.ctrlKey)) { if ((!unref(editEnabled) || !isTypableInputColumn(columnObj)) && (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 // set clipboard context only if single cell selected
if (rowObj.row[columnObj.title!]) { if (selectedRange.isSingleCell() && rowObj.row[columnObj.title!]) {
clipboardContext = { clipboardContext = {
value: rowObj.row[columnObj.title!], value: rowObj.row[columnObj.title!],
uidt: columnObj.uidt as UITypes, uidt: columnObj.uidt as UITypes,
@ -264,6 +277,7 @@ export function useMultiSelect(
} }
await copyValue() await copyValue()
break break
// paste - ctrl/cmd + v
case 86: case 86:
try { try {
// handle belongs to column // handle belongs to column
@ -297,7 +311,7 @@ export function useMultiSelect(
(relatedTableMeta as any)!.columns!, (relatedTableMeta as any)!.columns!,
) )
return await syncCellData?.({ ...selectedCell, updatedColumnTitle: foreignKeyColumn.title }) return await syncCellData?.({ ...activeCell, updatedColumnTitle: foreignKeyColumn.title })
} }
// if it's a virtual column excluding belongs to cell type skip paste // if it's a virtual column excluding belongs to cell type skip paste
@ -315,9 +329,9 @@ export function useMultiSelect(
isMysql.value, isMysql.value,
) )
e.preventDefault() e.preventDefault()
syncCellData?.(selectedCell) syncCellData?.(activeCell)
} else { } else {
clearCell(selectedCell as { row: number; col: number }, true) clearCell(activeCell as { row: number; col: number }, true)
makeEditable(rowObj, columnObj) makeEditable(rowObj, columnObj)
} }
} catch (error: any) { } catch (error: any) {
@ -346,15 +360,18 @@ export function useMultiSelect(
} }
} }
useEventListener(document, 'keydown', onKeyDown) const clearSelectedRange = selectedRange.clear.bind(selectedRange)
useEventListener(document, 'keydown', handleKeyDown)
useEventListener(document, 'mouseup', handleMouseUp)
return { return {
selectCell, handleMouseDown,
startSelectRange, handleMouseOver,
endSelectRange, clearSelectedRange,
clearSelectedRange: selectedRange.clear.bind(selectedRange),
copyValue, copyValue,
isCellSelected, isCellSelected,
selectedCell, activeCell,
handleCellClick,
} }
} }

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

@ -19,7 +19,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const cellRefs = ref<HTMLTableDataCellElement[]>([]) const cellRefs = ref<HTMLTableDataCellElement[]>([])
const { search } = useFieldQuery(view) const { search } = useFieldQuery()
const eventBus = useEventBus<SmartsheetStoreEvents>(Symbol('SmartsheetStore')) const eventBus = useEventBus<SmartsheetStoreEvents>(Symbol('SmartsheetStore'))

2
packages/nc-gui/lib/types.ts

@ -100,3 +100,5 @@ export interface SharedView {
export type importFileList = (UploadFile & { data: string | ArrayBuffer })[] export type importFileList = (UploadFile & { data: string | ArrayBuffer })[]
export type streamImportFileList = UploadFile[] export type streamImportFileList = UploadFile[]
export type Nullable<T> = { [K in keyof T]: T[K] | null }

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

@ -1,7 +1,7 @@
import { expect, Locator } from '@playwright/test'; import { expect, Locator } from '@playwright/test';
import { DashboardPage } from '..'; import { DashboardPage } from '..';
import BasePage from '../../Base'; import BasePage from '../../Base';
import { CellPageObject } from '../common/Cell'; import { CellPageObject, CellProps } from '../common/Cell';
import { ColumnPageObject } from './Column'; import { ColumnPageObject } from './Column';
import { ToolbarPage } from '../common/Toolbar'; import { ToolbarPage } from '../common/Toolbar';
import { ProjectMenuObject } from '../common/ProjectMenu'; import { ProjectMenuObject } from '../common/ProjectMenu';
@ -286,4 +286,34 @@ export class GridPage extends BasePage {
param.role === 'creator' || param.role === 'editor' ? 1 : 0 param.role === 'creator' || param.role === 'editor' ? 1 : 0
); );
} }
async selectRange({ start, end }: { start: CellProps; end: CellProps }) {
const startCell = await this.cell.get({ index: start.index, columnHeader: start.columnHeader });
const endCell = await this.cell.get({ index: end.index, columnHeader: end.columnHeader });
const page = await this.dashboard.get().page();
await startCell.hover();
await page.mouse.down();
await endCell.hover();
await page.mouse.up();
}
async selectedCount() {
return this.get().locator('.cell.active').count();
}
async copyWithKeyboard() {
await this.get().press((await this.isMacOs()) ? 'Meta+C' : 'Control+C');
await this.verifyToast({ message: 'Copied to clipboard' });
return this.getClipboardText();
}
async copyWithMouse({ index, columnHeader }: CellProps) {
await this.cell.get({ index, columnHeader }).click({ button: 'right' });
await this.get().page().getByTestId('context-menu-item-copy').click();
await this.verifyToast({ message: 'Copied to clipboard' });
return this.getClipboardText();
}
} }

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

@ -8,6 +8,11 @@ import { CheckboxCellPageObject } from './CheckboxCell';
import { RatingCellPageObject } from './RatingCell'; import { RatingCellPageObject } from './RatingCell';
import { DateCellPageObject } from './DateCell'; import { DateCellPageObject } from './DateCell';
export interface CellProps {
index?: number;
columnHeader: string;
}
export class CellPageObject extends BasePage { export class CellPageObject extends BasePage {
readonly parent: GridPage | SharedFormPage; readonly parent: GridPage | SharedFormPage;
readonly selectOption: SelectOptionCellPageObject; readonly selectOption: SelectOptionCellPageObject;
@ -26,7 +31,7 @@ export class CellPageObject extends BasePage {
this.date = new DateCellPageObject(this); this.date = new DateCellPageObject(this);
} }
get({ index, columnHeader }: { index?: number; columnHeader: string }): 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}"]`);
} else { } else {
@ -34,19 +39,16 @@ export class CellPageObject extends BasePage {
} }
} }
async click( async click({ index, columnHeader }: CellProps, ...options: Parameters<Locator['click']>) {
{ index, columnHeader }: { index: number; columnHeader: string },
...options: Parameters<Locator['click']>
) {
await this.get({ index, columnHeader }).click(...options); await this.get({ index, columnHeader }).click(...options);
await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable'); await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable');
} }
async dblclick({ index, columnHeader }: { index?: number; columnHeader: string }) { async dblclick({ index, columnHeader }: CellProps) {
return await this.get({ index, columnHeader }).dblclick(); return await this.get({ index, columnHeader }).dblclick();
} }
async fillText({ index, columnHeader, text }: { index?: number; columnHeader: string; text: string }) { async fillText({ index, columnHeader, text }: CellProps & { text: string }) {
await this.dblclick({ await this.dblclick({
index, index,
columnHeader, columnHeader,
@ -67,7 +69,7 @@ export class CellPageObject extends BasePage {
} }
} }
async inCellExpand({ index, columnHeader }: { index: number; columnHeader: string }) { async inCellExpand({ index, columnHeader }: CellProps) {
await this.get({ index, columnHeader }).hover(); await this.get({ index, columnHeader }).hover();
await this.waitForResponse({ await this.waitForResponse({
uiAction: this.get({ index, columnHeader }).locator('.nc-action-icon >> nth=0').click(), uiAction: this.get({ index, columnHeader }).locator('.nc-action-icon >> nth=0').click(),
@ -76,20 +78,20 @@ export class CellPageObject extends BasePage {
}); });
} }
async inCellAdd({ index, columnHeader }: { index: number; columnHeader: string }) { async inCellAdd({ index, columnHeader }: CellProps) {
await this.get({ index, columnHeader }).hover(); await this.get({ index, columnHeader }).hover();
await this.get({ index, columnHeader }).locator('.nc-action-icon.nc-plus').click(); await this.get({ index, columnHeader }).locator('.nc-action-icon.nc-plus').click();
} }
async verifyCellActiveSelected({ index, columnHeader }: { index: number; columnHeader: string }) { async verifyCellActiveSelected({ index, columnHeader }: CellProps) {
await expect(this.get({ index, columnHeader })).toHaveClass(/active/); await expect(this.get({ index, columnHeader })).toHaveClass(/active/);
} }
async verifyCellEditable({ index, columnHeader }: { index: number; columnHeader: string }) { async verifyCellEditable({ index, columnHeader }: CellProps) {
await this.get({ index, columnHeader }).isEditable(); await this.get({ index, columnHeader }).isEditable();
} }
async verify({ index, columnHeader, value }: { index: number; columnHeader: string; value: string | string[] }) { async verify({ index, columnHeader, value }: CellProps & { value: string | string[] }) {
const _verify = async text => { const _verify = async text => {
await expect await expect
.poll(async () => { .poll(async () => {
@ -115,9 +117,7 @@ export class CellPageObject extends BasePage {
index, index,
columnHeader, columnHeader,
expectedSrcValue, expectedSrcValue,
}: { }: CellProps & {
index: number;
columnHeader: string;
expectedSrcValue: string; expectedSrcValue: string;
}) { }) {
const _verify = async expectedQrCodeImgSrc => { const _verify = async expectedQrCodeImgSrc => {
@ -147,9 +147,7 @@ export class CellPageObject extends BasePage {
columnHeader, columnHeader,
count, count,
value, value,
}: { }: CellProps & {
index: number;
columnHeader: string;
count?: number; count?: number;
value: string[]; value: string[];
}) { }) {
@ -166,7 +164,7 @@ export class CellPageObject extends BasePage {
} }
} }
async unlinkVirtualCell({ index, columnHeader }: { index: number; columnHeader: string }) { async unlinkVirtualCell({ index, columnHeader }: CellProps) {
const cell = this.get({ index, columnHeader }); const cell = this.get({ index, columnHeader });
await cell.click(); await cell.click();
await cell.locator('.nc-icon.unlink-icon').click(); await cell.locator('.nc-icon.unlink-icon').click();
@ -200,10 +198,7 @@ export class CellPageObject extends BasePage {
); );
} }
async copyToClipboard( async copyToClipboard({ index, columnHeader }: CellProps, ...clickOptions: Parameters<Locator['click']>) {
{ index, columnHeader }: { index: number; columnHeader: string },
...clickOptions: Parameters<Locator['click']>
) {
await this.get({ index, columnHeader }).click(...clickOptions); await this.get({ index, columnHeader }).click(...clickOptions);
await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable'); await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable');

20
tests/playwright/pages/Dashboard/common/Toolbar/SearchData.ts

@ -0,0 +1,20 @@
import BasePage from '../../../Base';
import { ToolbarPage } from './index';
import { expect } from '@playwright/test';
export class ToolbarSearchDataPage extends BasePage {
readonly toolbar: ToolbarPage;
constructor(toolbar: ToolbarPage) {
super(toolbar.rootPage);
this.toolbar = toolbar;
}
get() {
return this.rootPage.getByTestId('search-data-input');
}
async verify(query: string) {
await expect(await this.get().inputValue()).toBe(query);
}
}

3
tests/playwright/pages/Dashboard/common/Toolbar/index.ts

@ -13,6 +13,7 @@ import { KanbanPage } from '../../Kanban';
import { FormPage } from '../../Form'; import { FormPage } from '../../Form';
import { ToolbarStackbyPage } from './StackBy'; import { ToolbarStackbyPage } from './StackBy';
import { ToolbarAddEditStackPage } from './AddEditKanbanStack'; import { ToolbarAddEditStackPage } from './AddEditKanbanStack';
import { ToolbarSearchDataPage } from './SearchData';
export class ToolbarPage extends BasePage { export class ToolbarPage extends BasePage {
readonly parent: GridPage | GalleryPage | FormPage | KanbanPage; readonly parent: GridPage | GalleryPage | FormPage | KanbanPage;
@ -24,6 +25,7 @@ export class ToolbarPage extends BasePage {
readonly actions: ToolbarActionsPage; readonly actions: ToolbarActionsPage;
readonly stackBy: ToolbarStackbyPage; readonly stackBy: ToolbarStackbyPage;
readonly addEditStack: ToolbarAddEditStackPage; readonly addEditStack: ToolbarAddEditStackPage;
readonly searchData: ToolbarSearchDataPage;
constructor(parent: GridPage | GalleryPage | FormPage | KanbanPage) { constructor(parent: GridPage | GalleryPage | FormPage | KanbanPage) {
super(parent.rootPage); super(parent.rootPage);
@ -36,6 +38,7 @@ export class ToolbarPage extends BasePage {
this.actions = new ToolbarActionsPage(this); this.actions = new ToolbarActionsPage(this);
this.stackBy = new ToolbarStackbyPage(this); this.stackBy = new ToolbarStackbyPage(this);
this.addEditStack = new ToolbarAddEditStackPage(this); this.addEditStack = new ToolbarAddEditStackPage(this);
this.searchData = new ToolbarSearchDataPage(this);
} }
get() { get() {

109
tests/playwright/tests/cellSelection.spec.ts

@ -0,0 +1,109 @@
import { expect, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import { GridPage } from '../pages/Dashboard/Grid';
import setup from '../setup';
test.describe('Verify cell selection', () => {
let dashboard: DashboardPage, grid: GridPage;
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
});
test('#1 when range is selected, it has correct number of selected cells', async () => {
await dashboard.treeView.openTable({ title: 'Customer' });
await grid.selectRange({
start: { index: 0, columnHeader: 'FirstName' },
end: { index: 2, columnHeader: 'Email' },
});
expect(await grid.selectedCount()).toBe(9);
});
test('#2 when copied with clipboard, it copies correct text', async () => {
await dashboard.treeView.openTable({ title: 'Customer' });
await grid.selectRange({
start: { index: 0, columnHeader: 'FirstName' },
end: { index: 1, columnHeader: 'LastName' },
});
expect(await grid.copyWithKeyboard()).toBe('MARY \t SMITH\n' + ' PATRICIA \t JOHNSON\n');
});
test('#3 when copied with mouse, it copies correct text', async () => {
await dashboard.treeView.openTable({ title: 'Customer' });
await grid.selectRange({
start: { index: 0, columnHeader: 'FirstName' },
end: { index: 1, columnHeader: 'LastName' },
});
expect(await grid.copyWithMouse({ index: 0, columnHeader: 'FirstName' })).toBe(
'MARY \t SMITH\n' + ' PATRICIA \t JOHNSON\n'
);
});
// FIXME: this is edge case, better be moved to integration tests
test('#4 when cell inside selection range is clicked, it clears previous selection', async () => {
await dashboard.treeView.openTable({ title: 'Country' });
await grid.selectRange({
start: { index: 0, columnHeader: 'Country' },
end: { index: 2, columnHeader: 'City List' },
});
expect(await grid.selectedCount()).toBe(9);
await grid.cell.get({ index: 0, columnHeader: 'Country' }).click();
expect(await grid.selectedCount()).toBe(1);
expect(await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Country' }));
});
// FIXME: this is edge case, better be moved to integration tests
test('#5 when cell outside selection range is clicked, it clears previous selection', async () => {
await dashboard.treeView.openTable({ title: 'Country' });
await grid.selectRange({
start: { index: 0, columnHeader: 'Country' },
end: { index: 2, columnHeader: 'City List' },
});
expect(await grid.selectedCount()).toBe(9);
await grid.cell.get({ index: 5, columnHeader: 'Country' }).click();
expect(await grid.selectedCount()).toBe(1);
expect(await grid.cell.verifyCellActiveSelected({ index: 5, columnHeader: 'Country' }));
});
// FIXME: this is edge case, better be moved to integration tests
test('#6 when selection ends on locked field, it still works as expected', async () => {
await dashboard.treeView.openTable({ title: 'Country' });
await dashboard.grid.toolbar.fields.toggleShowSystemFields();
await grid.selectRange({
start: { index: 2, columnHeader: 'City List' },
end: { index: 0, columnHeader: 'CountryId' },
});
expect(await grid.selectedCount()).toBe(12);
await grid.cell.get({ index: 1, columnHeader: 'Country' }).click();
expect(await grid.selectedCount()).toBe(1);
expect(await grid.cell.verifyCellActiveSelected({ index: 1, columnHeader: 'Country' }));
});
// FIXME: this is edge case, better be moved to integration tests
test('#7 when navigated with keyboard, only active cell is affected', async ({ page }) => {
await dashboard.treeView.openTable({ title: 'Country' });
await grid.selectRange({
start: { index: 0, columnHeader: 'Country' },
end: { index: 2, columnHeader: 'City List' },
});
await page.keyboard.press('ArrowRight');
expect(await grid.selectedCount()).toBe(1);
expect(await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'LastUpdate' }));
});
});

49
tests/playwright/tests/views.spec.ts

@ -1,14 +1,17 @@
import { test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard'; import { DashboardPage } from '../pages/Dashboard';
import { ToolbarPage } from '../pages/Dashboard/common/Toolbar';
import setup from '../setup'; import setup from '../setup';
test.describe('Views CRUD Operations', () => { test.describe('Views CRUD Operations', () => {
let dashboard: DashboardPage; let dashboard: DashboardPage;
let context: any; let context: any;
let toolbar: ToolbarPage;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
context = await setup({ page }); context = await setup({ page });
dashboard = new DashboardPage(page, context.project); dashboard = new DashboardPage(page, context.project);
toolbar = dashboard.grid.toolbar;
}); });
test('Create views, reorder and delete', async () => { test('Create views, reorder and delete', async () => {
@ -73,4 +76,48 @@ test.describe('Views CRUD Operations', () => {
index: 1, index: 1,
}); });
}); });
test('Save search query for each table and view', async () => {
await dashboard.treeView.openTable({ title: 'City' });
await toolbar.searchData.verify('');
await toolbar.searchData.get().fill('City-City');
await toolbar.searchData.verify('City-City');
await dashboard.viewSidebar.createGridView({ title: 'CityGrid' });
await toolbar.searchData.verify('');
await toolbar.searchData.get().fill('City-CityGrid');
await toolbar.searchData.verify('City-CityGrid');
await dashboard.viewSidebar.createGridView({ title: 'CityGrid2' });
await toolbar.searchData.verify('');
await toolbar.searchData.get().fill('City-CityGrid2');
await toolbar.searchData.verify('City-CityGrid2');
await dashboard.viewSidebar.openView({ title: 'CityGrid' });
await expect(dashboard.get().locator('[data-testid="grid-load-spinner"]')).toBeVisible();
await dashboard.grid.waitLoading();
await toolbar.searchData.verify('City-CityGrid');
await dashboard.viewSidebar.openView({ title: 'City' });
await expect(dashboard.get().locator('[data-testid="grid-load-spinner"]')).toBeVisible();
await dashboard.grid.waitLoading();
await toolbar.searchData.verify('City-City');
await dashboard.treeView.openTable({ title: 'Actor' });
await toolbar.searchData.verify('');
await dashboard.viewSidebar.createGridView({ title: 'ActorGrid' });
await toolbar.searchData.verify('');
await toolbar.searchData.get().fill('Actor-ActorGrid');
await toolbar.searchData.verify('Actor-ActorGrid');
await dashboard.viewSidebar.openView({ title: 'Actor' });
await expect(dashboard.get().locator('[data-testid="grid-load-spinner"]')).toBeVisible();
await dashboard.grid.waitLoading();
await toolbar.searchData.verify('');
await dashboard.treeView.openTable({ title: 'City', mode: '' });
await toolbar.searchData.verify('City-City');
});
}); });

Loading…
Cancel
Save