Browse Source

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

pull/4644/head
Wing-Kam Wong 2 years ago
parent
commit
70cf6c3c6f
  1. 2
      .github/ISSUE_TEMPLATE/--bug-report.yaml
  2. 1
      .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. 165
      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

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

@ -35,7 +35,7 @@ body:
- type: textarea
attributes:
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: |
or provide the following info
```

1
.github/workflows/uffizzi-preview.yml

@ -11,6 +11,7 @@ jobs:
cache-compose-file:
name: Cache Compose File
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
outputs:
compose-file-cache-key: ${{ env.COMPOSE_FILE_HASH }}
pr-number: ${{ env.PR_NUMBER }}

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)"
class="nc-locked-overlay"
@click.stop.prevent
@dblclick.stop.prevent
/>
</template>
</div>

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

@ -172,7 +172,7 @@ const getContainerScrollForElement = (
return scroll
}
const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyValue, isCellSelected, selectedCell } =
const { isCellSelected, activeCell, handleMouseDown, handleMouseOver, handleCellClick, clearSelectedRange, copyValue } =
useMultiSelect(
meta,
fields,
@ -201,9 +201,10 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
const altOrOptionKey = e.altKey
if (e.key === ' ') {
if (selectedCell.row !== null && !editEnabled) {
if (activeCell.row != null && !editEnabled) {
e.preventDefault()
const row = data.value[selectedCell.row]
clearSelectedRange()
const row = data.value[activeCell.row]
expandForm(row)
return true
}
@ -227,29 +228,33 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
selectedCell.row = 0
selectedCell.col = selectedCell.col ?? 0
clearSelectedRange()
activeCell.row = 0
activeCell.col = activeCell.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowDown':
e.preventDefault()
selectedCell.row = data.value.length - 1
selectedCell.col = selectedCell.col ?? 0
clearSelectedRange()
activeCell.row = data.value.length - 1
activeCell.col = activeCell.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowRight':
e.preventDefault()
selectedCell.row = selectedCell.row ?? 0
selectedCell.col = fields.value?.length - 1
clearSelectedRange()
activeCell.row = activeCell.row ?? 0
activeCell.col = fields.value?.length - 1
scrollToCell?.()
editEnabled = false
return true
case 'ArrowLeft':
e.preventDefault()
selectedCell.row = selectedCell.row ?? 0
selectedCell.col = 0
clearSelectedRange()
activeCell.row = activeCell.row ?? 0
activeCell.col = 0
scrollToCell?.()
editEnabled = false
return true
@ -279,7 +284,7 @@ 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
const columnObj = ctx.col !== undefined ? fields.value[ctx.col] : null
if (!ctx.updatedColumnTitle && isVirtualCol(columnObj)) {
return
@ -291,10 +296,10 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
)
function scrollToCell(row?: number | null, col?: number | null) {
row = row ?? selectedCell.row
col = col ?? selectedCell.col
row = row ?? activeCell.row
col = col ?? activeCell.col
if (row !== undefined && col !== undefined && row !== null && col !== null) {
if (row !== null && col !== null) {
// get active cell
const rows = tbodyEl.value?.querySelectorAll('tr')
const cols = rows?.[row].querySelectorAll('td')
@ -455,13 +460,14 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
/** On clicking outside of table reset active cell */
const smartTable = ref(null)
onClickOutside(smartTable, (e) => {
// do nothing if context menu was open
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
@ -482,25 +488,29 @@ onClickOutside(smartTable, (e) => {
return
}
selectedCell.row = null
selectedCell.col = null
clearSelectedRange()
activeCell.row = null
activeCell.col = null
})
const onNavigate = (dir: NavigateDir) => {
if (selectedCell.row === null || selectedCell.col === null) return
if (activeCell.row === null || activeCell.col === null) return
editEnabled = false
clearSelectedRange()
switch (dir) {
case NavigateDir.NEXT:
if (selectedCell.row < data.value.length - 1) {
selectedCell.row++
if (activeCell.row < data.value.length - 1) {
activeCell.row++
} else {
addEmptyRow()
selectedCell.row++
activeCell.row++
}
break
case NavigateDir.PREV:
if (selectedCell.row > 0) {
selectedCell.row--
if (activeCell.row > 0) {
activeCell.row--
}
break
}
@ -782,10 +792,10 @@ const closeAddColumnDropdown = () => {
:data-key="rowIndex + columnObj.id"
:data-col="columnObj.id"
: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)"
@mousedown="startSelectRange($event, rowIndex, colIndex)"
@mouseover="endSelectRange(rowIndex, colIndex)"
@contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })"
>
<div v-if="!switchingTab" class="w-full h-full">
@ -793,7 +803,7 @@ const closeAddColumnDropdown = () => {
v-if="isVirtualCol(columnObj)"
v-model="row.row[columnObj.title]"
:column="columnObj"
:active="selectedCell.col === colIndex && selectedCell.row === rowIndex"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
:row="row"
@navigate="onNavigate"
/>
@ -803,10 +813,10 @@ const closeAddColumnDropdown = () => {
v-model="row.row[columnObj.title]"
:column="columnObj"
:edit-enabled="
!!hasEditPermission && !!editEnabled && selectedCell.col === colIndex && selectedCell.row === rowIndex
!!hasEditPermission && !!editEnabled && activeCell.col === colIndex && activeCell.row === rowIndex
"
:row-index="rowIndex"
:active="selectedCell.col === colIndex && selectedCell.row === rowIndex"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
@update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow(row, columnObj.title, state)"
@navigate="onNavigate"
@ -872,7 +882,7 @@ const closeAddColumnDropdown = () => {
</div>
</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">
<!-- 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 { search, loadFieldQuery } = useFieldQuery(activeView)
const { search, loadFieldQuery } = useFieldQuery()
const isDropdownOpen = ref(false)
@ -36,7 +36,7 @@ watch(
() => activeView.value?.id,
(n, o) => {
if (n !== o) {
loadFieldQuery(activeView)
loadFieldQuery(activeView.value?.id)
}
},
{ immediate: true },
@ -76,6 +76,7 @@ function onPressEnter() {
class="max-w-[200px]"
:placeholder="$t('placeholder.filterQuery')"
:bordered="false"
data-testid="search-data-input"
@press-enter="onPressEnter"
>
<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'
export function useFieldQuery(view: Ref<ViewType | undefined>) {
export function useFieldQuery() {
// initial search object
const emptyFieldQueryObj = {
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', () => ({}))
// the fieldQueryObj under the current view
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
}
const search = useState<{ field: string; query: string }>('field-query-search', () => ({ ...emptyFieldQueryObj }))
// retrieve the fieldQueryObj of the given view id
// if it is not found in `searchMap`, init with emptyFieldQueryObj
const loadFieldQuery = (view: Ref<ViewType | undefined>) => {
if (!view.value?.id) return
if (!(view!.value!.id in searchMap.value)) {
searchMap.value[view!.value!.id!] = emptyFieldQueryObj
const loadFieldQuery = (id?: string) => {
if (!id) return
if (!(id in searchMap.value)) {
searchMap.value[id] = { ...emptyFieldQueryObj }
}
search.value = searchMap.value[view!.value!.id!]
search.value = searchMap.value[id]
}
return { search, loadFieldQuery }

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

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

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

@ -4,7 +4,7 @@ import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import type { Cell } from './cellRange'
import { CellRange } from './cellRange'
import convertCellData from './convertCellData'
import type { Row } from '~/lib'
import type { Nullable, Row } from '~/lib'
import {
copyTable,
extractPkFromRow,
@ -22,11 +22,13 @@ import {
useProject,
} from '#imports'
const MAIN_MOUSE_PRESSED = 0
/**
* Utility to help with multi-selecting rows/cells in the smartsheet
*/
export function useMultiSelect(
_meta: MaybeRef<TableType>,
_meta: MaybeRef<TableType | undefined>,
fields: MaybeRef<ColumnType[]>,
data: MaybeRef<Row[]>,
_editEnabled: MaybeRef<boolean>,
@ -51,15 +53,26 @@ export function useMultiSelect(
const editEnabled = ref(_editEnabled)
const selectedCell = reactive<Cell>({ row: null, col: null })
const selectedRange = reactive(new CellRange())
let isMouseDown = $ref(false)
const selectedRange = reactive(new CellRange())
const activeCell = reactive<Nullable<Cell>>({ row: null, col: null })
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) {
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 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 {
// 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
const cpRow = ctx?.row ?? selectedCell?.row
const cpCol = ctx?.col ?? selectedCell?.col
const cpRow = ctx?.row ?? activeCell.row
const cpCol = ctx?.col ?? activeCell.col
if (cpRow != null && cpCol != null) {
const rowObj = unref(data)[cpRow]
@ -93,29 +106,19 @@ export function useMultiSelect(
}
}
function selectCell(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) {
function handleMouseOver(row: number, col: number) {
if (!isMouseDown) {
return
}
selectedCell.row = null
selectedCell.col = null
selectedRange.endRange({ row, col })
}
function isCellSelected(row: number, col: number) {
if (selectedCell?.row === row && selectedCell?.col === col) {
if (activeCell.col === col && activeCell.row === row) {
return true
}
if (selectedRange.isEmpty()) {
if (selectedRange.start === null || selectedRange.end === null) {
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
const leftClickButton = 0
if (event?.button !== leftClickButton && isCellSelected(row, col)) {
if (event?.button !== MAIN_MOUSE_PRESSED && isCellSelected(row, col)) {
return
}
if (unref(editEnabled)) {
event.preventDefault()
return
editEnabled.value = false
isMouseDown = true
selectedRange.startRange({ row, col })
}
const handleCellClick = (event: MouseEvent, row: number, col: number) => {
isMouseDown = true
selectedRange.clear()
editEnabled.value = false
selectedRange.startRange({ row, col })
selectedRange.endRange({ row, col })
makeActive(row, col)
isMouseDown = false
}
useEventListener(document, 'mouseup', (e) => {
// if the editEnabled is false prevent the mouseup event for not select text
const handleMouseUp = (event: MouseEvent) => {
// timeout is needed, because we want to set cell as active AFTER all the child's click handler's called
// this is needed e.g. for date field edit, where two clicks had to be done - one to select cell, and another one to open date dropdown
setTimeout(() => {
makeActive(selectedRange.start.row, selectedRange.start.col)
}, 0)
// if the editEnabled is false, prevent selecting text on mouseUp
if (!unref(editEnabled)) {
e.preventDefault()
event.preventDefault()
}
isMouseDown = false
})
}
const onKeyDown = async (e: KeyboardEvent) => {
const handleKeyDown = async (e: KeyboardEvent) => {
// invoke the keyEventHandler if provided and return if it returns true
if (await keyEventHandler?.(e)) {
return true
}
if (!selectedRange.isEmpty()) {
// In case the user press tabs or arrows keys
selectedCell.row = selectedRange.start.row
selectedCell.col = selectedRange.start.col
if (activeCell.row === null || activeCell.col === null) {
return
}
if (selectedCell.row === null || selectedCell.col === null) return
/** on tab key press navigate through cells */
switch (e.key) {
case 'Tab':
@ -174,21 +182,21 @@ export function useMultiSelect(
selectedRange.clear()
if (e.shiftKey) {
if (selectedCell.col > 0) {
selectedCell.col--
if (activeCell.col > 0) {
activeCell.col--
editEnabled.value = false
} else if (selectedCell.row > 0) {
selectedCell.row--
selectedCell.col = unref(columnLength) - 1
} else if (activeCell.row > 0) {
activeCell.row--
activeCell.col = unref(columnLength) - 1
editEnabled.value = false
}
} else {
if (selectedCell.col < unref(columnLength) - 1) {
selectedCell.col++
if (activeCell.col < unref(columnLength) - 1) {
activeCell.col++
editEnabled.value = false
} else if (selectedCell.row < unref(data).length - 1) {
selectedCell.row++
selectedCell.col = 0
} else if (activeCell.row < unref(data).length - 1) {
activeCell.row++
activeCell.col = 0
editEnabled.value = false
}
}
@ -198,63 +206,68 @@ export function useMultiSelect(
case 'Enter':
e.preventDefault()
selectedRange.clear()
makeEditable(unref(data)[selectedCell.row], unref(fields)[selectedCell.col])
makeEditable(unref(data)[activeCell.row], unref(fields)[activeCell.col])
break
/** on delete key press clear cell */
case 'Delete':
e.preventDefault()
selectedRange.clear()
await clearCell(selectedCell as { row: number; col: number })
await clearCell(activeCell as { row: number; col: number })
break
/** on arrow key press navigate through cells */
case 'ArrowRight':
e.preventDefault()
selectedRange.clear()
if (selectedCell.col < unref(columnLength) - 1) {
selectedCell.col++
if (activeCell.col < unref(columnLength) - 1) {
activeCell.col++
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowLeft':
selectedRange.clear()
e.preventDefault()
if (selectedCell.col > 0) {
selectedCell.col--
selectedRange.clear()
if (activeCell.col > 0) {
activeCell.col--
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowUp':
selectedRange.clear()
e.preventDefault()
if (selectedCell.row > 0) {
selectedCell.row--
selectedRange.clear()
if (activeCell.row > 0) {
activeCell.row--
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowDown':
selectedRange.clear()
e.preventDefault()
if (selectedCell.row < unref(data).length - 1) {
selectedCell.row++
selectedRange.clear()
if (activeCell.row < unref(data).length - 1) {
activeCell.row++
scrollToActiveCell?.()
editEnabled.value = false
}
break
default:
{
const rowObj = unref(data)[selectedCell.row]
const columnObj = unref(fields)[selectedCell.col]
const rowObj = unref(data)[activeCell.row]
const columnObj = unref(fields)[activeCell.col]
if ((!unref(editEnabled) || !isTypableInputColumn(columnObj)) && (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!]) {
if (selectedRange.isSingleCell() && rowObj.row[columnObj.title!]) {
clipboardContext = {
value: rowObj.row[columnObj.title!],
uidt: columnObj.uidt as UITypes,
@ -264,6 +277,7 @@ export function useMultiSelect(
}
await copyValue()
break
// paste - ctrl/cmd + v
case 86:
try {
// handle belongs to column
@ -297,7 +311,7 @@ export function useMultiSelect(
(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
@ -315,9 +329,9 @@ export function useMultiSelect(
isMysql.value,
)
e.preventDefault()
syncCellData?.(selectedCell)
syncCellData?.(activeCell)
} else {
clearCell(selectedCell as { row: number; col: number }, true)
clearCell(activeCell as { row: number; col: number }, true)
makeEditable(rowObj, columnObj)
}
} 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 {
selectCell,
startSelectRange,
endSelectRange,
clearSelectedRange: selectedRange.clear.bind(selectedRange),
handleMouseDown,
handleMouseOver,
clearSelectedRange,
copyValue,
isCellSelected,
selectedCell,
activeCell,
handleCellClick,
}
}

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

@ -19,7 +19,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const cellRefs = ref<HTMLTableDataCellElement[]>([])
const { search } = useFieldQuery(view)
const { search } = useFieldQuery()
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 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 { DashboardPage } from '..';
import BasePage from '../../Base';
import { CellPageObject } from '../common/Cell';
import { CellPageObject, CellProps } from '../common/Cell';
import { ColumnPageObject } from './Column';
import { ToolbarPage } from '../common/Toolbar';
import { ProjectMenuObject } from '../common/ProjectMenu';
@ -286,4 +286,34 @@ export class GridPage extends BasePage {
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 { DateCellPageObject } from './DateCell';
export interface CellProps {
index?: number;
columnHeader: string;
}
export class CellPageObject extends BasePage {
readonly parent: GridPage | SharedFormPage;
readonly selectOption: SelectOptionCellPageObject;
@ -26,7 +31,7 @@ export class CellPageObject extends BasePage {
this.date = new DateCellPageObject(this);
}
get({ index, columnHeader }: { index?: number; columnHeader: string }): Locator {
get({ index, columnHeader }: CellProps): Locator {
if (this.parent instanceof SharedFormPage) {
return this.parent.get().locator(`[data-testid="nc-form-input-cell-${columnHeader}"]`);
} else {
@ -34,19 +39,16 @@ export class CellPageObject extends BasePage {
}
}
async click(
{ index, columnHeader }: { index: number; columnHeader: string },
...options: Parameters<Locator['click']>
) {
async click({ index, columnHeader }: CellProps, ...options: Parameters<Locator['click']>) {
await this.get({ index, columnHeader }).click(...options);
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();
}
async fillText({ index, columnHeader, text }: { index?: number; columnHeader: string; text: string }) {
async fillText({ index, columnHeader, text }: CellProps & { text: string }) {
await this.dblclick({
index,
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.waitForResponse({
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 }).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/);
}
async verifyCellEditable({ index, columnHeader }: { index: number; columnHeader: string }) {
async verifyCellEditable({ index, columnHeader }: CellProps) {
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 => {
await expect
.poll(async () => {
@ -115,9 +117,7 @@ export class CellPageObject extends BasePage {
index,
columnHeader,
expectedSrcValue,
}: {
index: number;
columnHeader: string;
}: CellProps & {
expectedSrcValue: string;
}) {
const _verify = async expectedQrCodeImgSrc => {
@ -147,9 +147,7 @@ export class CellPageObject extends BasePage {
columnHeader,
count,
value,
}: {
index: number;
columnHeader: string;
}: CellProps & {
count?: number;
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 });
await cell.click();
await cell.locator('.nc-icon.unlink-icon').click();
@ -200,10 +198,7 @@ export class CellPageObject extends BasePage {
);
}
async copyToClipboard(
{ index, columnHeader }: { index: number; columnHeader: string },
...clickOptions: Parameters<Locator['click']>
) {
async copyToClipboard({ index, columnHeader }: CellProps, ...clickOptions: Parameters<Locator['click']>) {
await this.get({ index, columnHeader }).click(...clickOptions);
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 { ToolbarStackbyPage } from './StackBy';
import { ToolbarAddEditStackPage } from './AddEditKanbanStack';
import { ToolbarSearchDataPage } from './SearchData';
export class ToolbarPage extends BasePage {
readonly parent: GridPage | GalleryPage | FormPage | KanbanPage;
@ -24,6 +25,7 @@ export class ToolbarPage extends BasePage {
readonly actions: ToolbarActionsPage;
readonly stackBy: ToolbarStackbyPage;
readonly addEditStack: ToolbarAddEditStackPage;
readonly searchData: ToolbarSearchDataPage;
constructor(parent: GridPage | GalleryPage | FormPage | KanbanPage) {
super(parent.rootPage);
@ -36,6 +38,7 @@ export class ToolbarPage extends BasePage {
this.actions = new ToolbarActionsPage(this);
this.stackBy = new ToolbarStackbyPage(this);
this.addEditStack = new ToolbarAddEditStackPage(this);
this.searchData = new ToolbarSearchDataPage(this);
}
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 { ToolbarPage } from '../pages/Dashboard/common/Toolbar';
import setup from '../setup';
test.describe('Views CRUD Operations', () => {
let dashboard: DashboardPage;
let context: any;
let toolbar: ToolbarPage;
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
toolbar = dashboard.grid.toolbar;
});
test('Create views, reorder and delete', async () => {
@ -73,4 +76,48 @@ test.describe('Views CRUD Operations', () => {
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