Browse Source

feat: virtual scroll for grid (#8356)

* feat: virtual scroll for grid

* feat: improve virtual scroll

* fix: remove unused expose & ref

* feat: move row ltar helpers to parent level

* fix: use shared composable for useMetas

* fix: column add issue

* fix: reload issue

* feat: move cell state to computed

* chore: lint

* fix: null check for sticky field

* fix: PR requested changes

* fix: shared views

* fix: provide row store calls

* test: avoid all rows selector

* fix: group by

* fix: include isVirtualCol in cellMeta

* fix: split colMeta and cellMeta

* chore: lint

* test: edit column flakiness

* test: renderColumn for dashboard grid

* test: user column test flakiness
fix/src-filter
Mert E 7 months ago committed by GitHub
parent
commit
fed1c7ba5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      packages/nc-gui/components/shared-view/Calendar.vue
  2. 2
      packages/nc-gui/components/shared-view/Gallery.vue
  3. 2
      packages/nc-gui/components/shared-view/Grid.vue
  4. 2
      packages/nc-gui/components/shared-view/Kanban.vue
  5. 2
      packages/nc-gui/components/shared-view/Map.vue
  6. 35
      packages/nc-gui/components/smartsheet/Cell.vue
  7. 1
      packages/nc-gui/components/smartsheet/Form.vue
  8. 17
      packages/nc-gui/components/smartsheet/Row.vue
  9. 5
      packages/nc-gui/components/smartsheet/SharedMapMarkerPopup.vue
  10. 4
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  11. 35
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  12. 4
      packages/nc-gui/components/smartsheet/column/DefaultValue.vue
  13. 2
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  14. 1
      packages/nc-gui/components/smartsheet/grid/GroupByTable.vue
  15. 531
      packages/nc-gui/components/smartsheet/grid/Table.vue
  16. 5
      packages/nc-gui/components/tabs/Smartsheet.vue
  17. 2
      packages/nc-gui/composables/useExpandedFormStore.ts
  18. 4
      packages/nc-gui/composables/useMetas.ts
  19. 48
      packages/nc-gui/composables/useMultiSelect/index.ts
  20. 4
      packages/nc-gui/composables/useSharedFormViewStore.ts
  21. 249
      packages/nc-gui/composables/useSmartsheetLtarHelpers.ts
  22. 240
      packages/nc-gui/composables/useSmartsheetRowStore.ts
  23. 1
      packages/nc-gui/lib/types.ts
  24. 3
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  25. 44
      tests/playwright/pages/Dashboard/Grid/index.ts
  26. 2
      tests/playwright/pages/Dashboard/common/Cell/UserOptionCell.ts
  27. 1
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  28. 1
      tests/playwright/pages/Dashboard/common/Topbar/index.ts
  29. 6
      tests/playwright/tests/db/columns/columnUserSelect.spec.ts
  30. 3
      tests/playwright/tests/db/features/keyboardShortcuts.spec.ts

2
packages/nc-gui/components/shared-view/Calendar.vue

@ -28,6 +28,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView)

2
packages/nc-gui/components/shared-view/Gallery.vue

@ -32,6 +32,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView)

2
packages/nc-gui/components/shared-view/Grid.vue

@ -43,6 +43,8 @@ provide(IsLockedInj, isLocked)
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideViewGroupBy(sharedView, meta, xWhere, true)
useProvideSmartsheetLtarHelpers(meta)
if (signedIn.value) {
try {
await loadProject()

2
packages/nc-gui/components/shared-view/Kanban.vue

@ -27,6 +27,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView, true)

2
packages/nc-gui/components/shared-view/Map.vue

@ -27,6 +27,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideMapViewStore(meta, sharedView, true)

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

@ -154,43 +154,10 @@ const onContextmenu = (e: MouseEvent) => {
e.stopPropagation()
}
}
// Todo: move intersection logic to a separate component or a vue directive
const intersected = ref(false)
const intersectionObserver = ref<IntersectionObserver>()
const elementToObserve = ref<Element>()
// load the cell only when it is in the viewport
function initIntersectionObserver() {
intersectionObserver.value = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// if the cell is in the viewport, load the cell and disconnect the observer
if (entry.isIntersecting) {
intersected.value = true
intersectionObserver.value?.disconnect()
intersectionObserver.value = undefined
}
})
})
}
// observe the cell when it is mounted
onMounted(() => {
initIntersectionObserver()
intersectionObserver.value?.observe(elementToObserve.value!)
})
// disconnect the observer when the cell is unmounted
onUnmounted(() => {
intersectionObserver.value?.disconnect()
})
</script>
<template>
<div
ref="elementToObserve"
:class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{
@ -214,7 +181,6 @@ onUnmounted(() => {
@keydown.shift.enter.exact="navigate(NavigateDir.PREV, $event)"
>
<template v-if="column">
<template v-if="intersected">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" :virtual="props.virtual" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
@ -259,7 +225,6 @@ onUnmounted(() => {
class="nc-locked-overlay"
/>
</template>
</template>
</div>
</template>

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

@ -120,7 +120,6 @@ reloadEventHook.on(async () => {
const { fields, showAll, hideAll } = useViewColumnsOrThrow()
const { state, row } = useProvideSmartsheetRowStore(
meta,
ref({
row: formState.value,
oldRow: {},

17
packages/nc-gui/components/smartsheet/Row.vue

@ -1,6 +1,4 @@
<script lang="ts" setup>
import type { Ref } from 'vue'
import type { TableType } from 'nocodb-sdk'
import type { Row } from '#imports'
import {
ReloadRowDataHookInj,
@ -10,7 +8,6 @@ import {
provide,
toRef,
useProvideSmartsheetRowStore,
useSmartsheetStoreOrThrow,
} from '#imports'
const props = defineProps<{
@ -19,12 +16,7 @@ const props = defineProps<{
const currentRow = toRef(props, 'row')
const { meta } = useSmartsheetStoreOrThrow()
const { isNew, state, syncLTARRefs, clearLTARCell, addLTARRef, cleaMMCell } = useProvideSmartsheetRowStore(
meta as Ref<TableType>,
currentRow,
)
const { isNew, state } = useProvideSmartsheetRowStore(currentRow)
const reloadViewDataTrigger = inject(ReloadViewDataHookInj)!
@ -39,13 +31,6 @@ reloadHook.on((params) => {
})
provide(ReloadRowDataHookInj, reloadHook)
defineExpose({
syncLTARRefs,
clearLTARCell,
addLTARRef,
cleaMMCell,
})
</script>
<template>

5
packages/nc-gui/components/smartsheet/SharedMapMarkerPopup.vue

@ -1,7 +1,4 @@
<script lang="ts" setup>
import type { ColumnType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { isVirtualCol } from 'nocodb-sdk'
import {
ActiveViewInj,
@ -52,7 +49,7 @@ provide(ReloadRowDataHookInj, reloadViewDataHook!)
const currentRow = toRef(props, 'row')
useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow)
useProvideSmartsheetRowStore(currentRow)
</script>
<template>

4
packages/nc-gui/components/smartsheet/TableDataCell.vue

@ -1,15 +1,13 @@
<script lang="ts" setup>
import { CellClickHookInj, CurrentCellInj, createEventHook, ref } from '#imports'
const el = ref<HTMLTableDataCellElement>()
const el = ref<HTMLElement>()
const cellClickHook = createEventHook()
provide(CellClickHookInj, cellClickHook)
provide(CurrentCellInj, el)
defineExpose({ el })
</script>
<template>

35
packages/nc-gui/components/smartsheet/VirtualCell.vue

@ -62,43 +62,10 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
if (!isForm.value) e.stopImmediatePropagation()
}
// Todo: move intersection logic to a separate component or a vue directive
const intersected = ref(false)
const intersectionObserver = ref<IntersectionObserver>()
const elementToObserve = ref<Element>()
// load the cell only when it is in the viewport
function initIntersectionObserver() {
intersectionObserver.value = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// if the cell is in the viewport, load the cell and disconnect the observer
if (entry.isIntersecting) {
intersected.value = true
intersectionObserver.value?.disconnect()
intersectionObserver.value = undefined
}
})
})
}
// observe the cell when it is mounted
onMounted(() => {
initIntersectionObserver()
intersectionObserver.value?.observe(elementToObserve.value!)
})
// disconnect the observer when the cell is unmounted
onUnmounted(() => {
intersectionObserver.value?.disconnect()
})
</script>
<template>
<div
ref="elementToObserve"
class="nc-virtual-cell w-full flex items-center"
:class="{
'text-right justify-end': isGrid && !isForm && isRollup(column) && !isExpandedForm,
@ -107,7 +74,6 @@ onUnmounted(() => {
@keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
>
<template v-if="intersected">
<LazyVirtualCellLinks v-if="isLink(column)" />
<LazyVirtualCellHasMany v-else-if="isHm(column)" />
<LazyVirtualCellManyToMany v-else-if="isMm(column)" />
@ -121,7 +87,6 @@ onUnmounted(() => {
<LazyVirtualCellLookup v-else-if="isLookup(column)" />
<LazyCellReadOnlyDateTimePicker v-else-if="isCreatedOrLastModifiedTimeCol(column)" :model-value="modelValue" />
<LazyCellReadOnlyUser v-else-if="isCreatedOrLastModifiedByCol(column)" :model-value="modelValue" />
</template>
</div>
</template>

4
packages/nc-gui/components/smartsheet/column/DefaultValue.vue

@ -7,8 +7,6 @@ const props = defineProps<{
}>()
const emits = defineEmits(['update:value'])
const meta = inject(MetaInj, ref())
provide(EditColumnInj, ref(true))
const vModel = useVModel(props, 'value', emits)
@ -20,7 +18,7 @@ const rowRef = ref({
},
})
useProvideSmartsheetRowStore(meta, rowRef)
useProvideSmartsheetRowStore(rowRef)
const cdfValue = ref<string | null>(null)

2
packages/nc-gui/components/smartsheet/expanded-form/index.vue

@ -157,6 +157,8 @@ const duplicatingRowInProgress = ref(false)
useProvideSmartsheetStore(ref({}) as Ref<ViewType>, meta)
useProvideSmartsheetLtarHelpers(meta)
watch(
state,
() => {

1
packages/nc-gui/components/smartsheet/grid/GroupByTable.vue

@ -148,6 +148,7 @@ const pagination = computed(() => {
:hide-header="true"
:pagination="pagination"
:disable-skeleton="true"
:disable-virtual-y="true"
/>
</template>

531
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -90,6 +90,8 @@ const props = defineProps<{
extraStyle?: string
}
disableSkeleton?: boolean
disableVirtualX?: boolean
disableVirtualY?: boolean
}>()
const emits = defineEmits(['update:selectedAllRecords', 'bulkUpdateDlg', 'toggleOptimisedQuery'])
@ -102,6 +104,16 @@ const dataRef = toRef(props, 'data')
const paginationStyleRef = toRef(props, 'pagination')
const headerOnly = toRef(props, 'headerOnly')
const hideHeader = toRef(props, 'hideHeader')
const disableSkeleton = toRef(props, 'disableSkeleton')
const disableVirtualX = toRef(props, 'disableVirtualX')
const disableVirtualY = toRef(props, 'disableVirtualY')
const { api } = useApi()
const {
@ -115,9 +127,6 @@ const {
deleteRangeOfRows,
removeRowIfNew,
bulkUpdateRows,
headerOnly,
hideHeader,
disableSkeleton,
} = props
// #Injections
@ -171,23 +180,15 @@ const {
predictedNextColumn,
predictingNextFormulas,
predictedNextFormulas,
predictNextColumn: _predictNextColumn,
predictNextFormulas: _predictNextFormulas,
predictNextColumn,
predictNextFormulas,
} = useNocoEe().table
const predictNextColumn = async () => {
await _predictNextColumn(meta)
}
const predictNextFormulas = async () => {
await _predictNextFormulas(meta)
}
const { paste } = usePaste()
// #Refs
const { addLTARRef, syncLTARRefs, clearLTARCell, cleaMMCell } = useSmartsheetLtarHelpersOrThrow()
const rowRefs = ref<any[]>()
// #Refs
const smartTable = ref(null)
@ -199,7 +200,7 @@ const tableBodyEl = ref<HTMLElement>()
const fillHandle = ref<HTMLElement>()
const gridRect = useElementBounding(gridWrapper)
const { height: tableHeadHeight, width: _tableHeadWidth } = useElementBounding(tableHeadEl)
const isViewColumnsLoading = computed(() => _isViewColumnsLoading.value || !meta.value)
@ -279,8 +280,8 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
if (isVirtualCol(columnObj)) {
let mmClearResult
if (isMm(columnObj) && rowRefs.value) {
mmClearResult = await rowRefs.value[ctx.row]!.cleaMMCell(columnObj)
if (isMm(columnObj) && rowObj) {
mmClearResult = await cleaMMCell(rowObj, columnObj)
}
addUndo({
@ -298,12 +299,11 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) &&
columnObj.id === col.id
) {
if (rowRefs.value) {
if (isBt(columnObj) || isOo(columnObj)) {
rowObj.row[columnObj.title] = row.row[columnObj.title]
await rowRefs.value[ctx.row]!.addLTARRef(rowObj.row[columnObj.title], columnObj)
await rowRefs.value[ctx.row]!.syncLTARRefs(rowObj.row)
await addLTARRef(rowObj, rowObj.row[columnObj.title], columnObj)
await syncLTARRefs(rowObj, rowObj.row)
} else if (isMm(columnObj)) {
await api.dbDataTableRow.nestedLink(
meta.value?.id as string,
@ -313,7 +313,6 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
)
rowObj.row[columnObj.title] = mmClearResult?.length ? mmClearResult?.length : null
}
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
activeCell.col = ctx.col
@ -339,12 +338,10 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
const rowObj = dataRef.value[ctx.row]
const columnObj = fields.value[ctx.col]
if (rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) && columnObj.id === col.id) {
if (rowRefs.value) {
if (isBt(columnObj) || isOo(columnObj)) {
await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
await clearLTARCell(rowObj, columnObj)
} else if (isMm(columnObj)) {
await rowRefs.value[ctx.row]!.cleaMMCell(columnObj)
}
await cleaMMCell(rowObj, columnObj)
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
activeCell.col = ctx.col
@ -362,7 +359,7 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
},
scope: defineViewScope({ view: view.value }),
})
if ((isBt(columnObj) || isOo(columnObj)) && rowRefs.value) await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
if (isBt(columnObj) || isOo(columnObj)) await clearLTARCell(rowObj, columnObj)
return
}
@ -426,7 +423,7 @@ const visibleColLength = computed(() => fields.value?.length)
const gridWrapperClass = computed<string>(() => {
const classes = []
if (headerOnly !== true) {
if (headerOnly.value !== true) {
if (!scrollParent.value) {
classes.push('nc-scrollbar-x-lg !overflow-auto')
}
@ -453,10 +450,33 @@ const dummyRowDataForLoading = computed(() => {
const showSkeleton = computed(
() =>
(disableSkeleton !== true && (isViewDataLoading.value || isPaginationLoading.value || isViewColumnsLoading.value)) ||
(disableSkeleton.value !== true && (isViewDataLoading.value || isPaginationLoading.value || isViewColumnsLoading.value)) ||
!meta.value,
)
const cellMeta = computed(() => {
return dataRef.value.map((row) => {
return fields.value.map((col) => {
return {
isColumnRequiredAndNull: isColumnRequiredAndNull(col, row.row),
}
})
})
})
const colMeta = computed(() => {
return fields.value.map((col) => {
return {
isLookup: isLinksOrLTAR(col),
isRollup: isBt(col),
isFormula: isFormula(col),
isCreatedOrLastModifiedTimeCol: isCreatedOrLastModifiedTimeCol(col),
isCreatedOrLastModifiedByCol: isCreatedOrLastModifiedByCol(col),
isVirtualCol: isVirtualCol(col),
}
})
})
// #Grid
function openColumnCreate(data: any) {
@ -503,7 +523,12 @@ const onNewRecordToFormClick = () => {
}
const getContainerScrollForElement = (
el: HTMLElement,
childPos: {
top: number
right: number
bottom: number
left: number
},
container: HTMLElement,
offset?: {
top?: number
@ -512,7 +537,6 @@ const getContainerScrollForElement = (
right?: number
},
) => {
const childPos = el.getBoundingClientRect()
const parentPos = container.getBoundingClientRect()
// provide an extra offset to show the prev/next/up/bottom cell
@ -524,10 +548,10 @@ const getContainerScrollForElement = (
const stickyColsWidth = numColWidth + primaryColWidth
const relativePos = {
top: childPos.top - parentPos.top,
right: childPos.right - parentPos.right,
bottom: childPos.bottom - parentPos.bottom,
left: childPos.left - parentPos.left - stickyColsWidth,
right: childPos.right + numColWidth - parentPos.width - container.scrollLeft,
left: childPos.left + numColWidth - container.scrollLeft - stickyColsWidth,
bottom: childPos.bottom - parentPos.height - container.scrollTop,
top: childPos.top - container.scrollTop,
}
const scroll = {
@ -561,7 +585,8 @@ const getContainerScrollForElement = (
}
const {
isCellSelected,
selectRangeMap,
fillRangeMap,
activeCell,
handleMouseDown,
handleMouseOver,
@ -572,7 +597,6 @@ const {
resetSelectedRange,
makeActive,
selectedRange,
isCellInFillRange,
isFillMode,
} = useMultiSelect(
meta,
@ -947,22 +971,46 @@ async function clearSelectedRangeOfCells() {
await bulkUpdateRows?.(rows, props)
}
const colPositions = computed(() => {
return fields.value
.filter((col) => col.id && gridViewCols.value[col.id] && gridViewCols.value[col.id].width && gridViewCols.value[col.id].show)
.map((col) => {
return +gridViewCols.value[col.id!]!.width!.replace('px', '') || 200
})
.reduce(
(acc, width, i) => {
acc.push(acc[i] + width)
return acc
},
[0],
)
})
const scrollWrapper = computed(() => scrollParent.value || gridWrapper.value)
function scrollToCell(row?: number | null, col?: number | null) {
row = row ?? activeCell.row
col = col ?? activeCell.col
if (row !== null && col !== null) {
// get active cell
const rows = tableBodyEl.value?.querySelectorAll('tr')
const cols = rows?.[row]?.querySelectorAll('td')
const td = cols?.[col === 0 ? 0 : col + 1]
if (row !== null && col !== null && scrollWrapper.value) {
// calculate cell position
const td = {
top: row * rowHeightInPx[`${props.rowHeight}`],
left: colPositions.value[col],
right:
col === fields.value.length - 1 ? colPositions.value[colPositions.value.length - 1] + 200 : colPositions.value[col + 1],
bottom: (row + 1) * rowHeightInPx[`${props.rowHeight}`],
}
if (!td || !scrollWrapper.value) return
const tdScroll = getContainerScrollForElement(td, scrollWrapper.value, {
top: 9,
bottom: (tableHeadHeight.value || 40) + 9,
right: 9,
})
const { height: headerHeight } = tableHeadEl.value!.getBoundingClientRect()
const tdScroll = getContainerScrollForElement(td, scrollWrapper.value, { top: headerHeight || 40, bottom: 9, right: 9 })
if (isGroupBy.value) {
tdScroll.top = scrollWrapper.value.scrollTop
}
// if first column set left to 0 since it's sticky it will be visible and calculated value will be wrong
// setting left to 0 will make it scroll to the left
@ -970,20 +1018,11 @@ function scrollToCell(row?: number | null, col?: number | null) {
tdScroll.left = 0
}
if (rows && row === rows.length - 2) {
// if last row make 'Add New Row' visible
const lastRow = rows[rows.length - 1] || rows[rows.length - 2]
const lastRowScroll = getContainerScrollForElement(lastRow, scrollWrapper.value, {
top: headerHeight || 40,
bottom: 9,
right: 9,
})
if (row === dataRef.value.length - 1) {
scrollWrapper.value.scrollTo({
top: lastRowScroll.top,
top: isGroupBy.value ? scrollWrapper.value.scrollTop : scrollWrapper.value.scrollHeight,
left:
cols && col === cols.length - 2 // if corner cell
col === fields.value.length - 1 // if corner cell
? scrollWrapper.value.scrollWidth
: tdScroll.left,
behavior: 'smooth',
@ -991,7 +1030,7 @@ function scrollToCell(row?: number | null, col?: number | null) {
return
}
if (cols && col === cols.length - 2) {
if (col === fields.value.length - 1) {
// if last column make 'Add New Column' visible
scrollWrapper.value.scrollTo({
top: tdScroll.top,
@ -1026,14 +1065,11 @@ async function resetAndChangePage(row: number, col: number, pageChange?: number)
}
const saveOrUpdateRecords = async (args: { metaValue?: TableType; viewMetaValue?: ViewType; data?: any } = {}) => {
let index = -1
for (const currentRow of args.data || dataRef.value) {
index++
/** if new record save row and save the LTAR cells */
if (currentRow.rowMeta.new) {
const syncLTARRefs = rowRefs.value?.[index]?.syncLTARRefs
const savedRow = await updateOrSaveRow?.(currentRow, '', {}, args)
await syncLTARRefs?.(savedRow, args)
await syncLTARRefs?.(currentRow, savedRow, args)
currentRow.rowMeta.changed = false
continue
}
@ -1078,6 +1114,133 @@ const loadColumn = (title: string, tp: string, colOptions?: any) => {
persistMenu.value = false
}
// Virtual scroll
const maxGridWidth = computed(() => {
// 64 for the row number column
// count first column twice because it's sticky
// 100 for add new column
return colPositions.value[colPositions.value.length - 1] + colPositions.value[1] + 64 + 100
})
const maxGridHeight = computed(() => {
// 2 extra rows for the add new row and the sticky header
return dataRef.value.length * rowHeightInPx[`${props.rowHeight}`] + 2 * rowHeightInPx[`${props.rowHeight}`]
})
const colSlice = ref({
start: 0,
end: 0,
})
const rowSlice = ref({
start: 0,
end: 0,
})
const VIRTUAL_MARGIN = 10
const calculateSlices = () => {
// if the grid is not rendered yet
if (!scrollWrapper.value || !gridWrapper.value) {
colSlice.value = {
start: 0,
end: 0,
}
rowSlice.value = {
start: 0,
end: 0,
}
return
}
let renderStart = 0
if (disableVirtualX.value !== true) {
// use binary search to find the start and end columns
let startRange = 0
let endRange = colPositions.value.length - 1
while (endRange !== startRange) {
const middle = Math.floor((endRange - startRange) / 2 + startRange)
if (
colPositions.value[middle] <= scrollWrapper.value.scrollLeft &&
colPositions.value[middle + 1] > scrollWrapper.value.scrollLeft
) {
renderStart = middle
break
}
if (middle === startRange) {
renderStart = endRange
break
} else {
if (colPositions.value[middle] <= scrollWrapper.value.scrollLeft) {
startRange = middle
} else {
endRange = middle
}
}
}
let renderEnd = 0
let renderEndFound = false
for (let i = renderStart; i < colPositions.value.length; i++) {
if (colPositions.value[i] > gridWrapper.value.clientWidth + scrollWrapper.value.scrollLeft) {
renderEnd = i
renderEndFound = true
break
}
}
colSlice.value = {
start: Math.max(0, renderStart - VIRTUAL_MARGIN),
end: renderEndFound ? Math.min(fields.value.length, renderEnd + VIRTUAL_MARGIN) : fields.value.length,
}
}
if (disableVirtualY.value !== true) {
const rowHeight = rowHeightInPx[`${props.rowHeight}`]
const rowRenderStart = Math.max(0, Math.floor(scrollWrapper.value.scrollTop / rowHeight) - VIRTUAL_MARGIN)
const rowRenderEnd = Math.min(
dataRef.value.length,
rowRenderStart + Math.ceil(gridWrapper.value.clientHeight / rowHeight) + VIRTUAL_MARGIN,
)
rowSlice.value = {
start: rowRenderStart,
end: rowRenderEnd,
}
}
}
const visibleFields = computed(() => {
if (disableVirtualX.value) return fields.value.map((field, index) => ({ field, index })).filter((f) => f.index !== 0)
// return data as { field, index } to keep track of the index
const vFields = fields.value.slice(colSlice.value.start, colSlice.value.end)
return vFields.map((field, index) => ({ field, index: index + colSlice.value.start })).filter((f) => f.index !== 0)
})
const visibleData = computed(() => {
if (disableVirtualY.value) return dataRef.value.map((row, index) => ({ row, index }))
// return data as { row, index } to keep track of the index
return dataRef.value.slice(rowSlice.value.start, rowSlice.value.end).map((row, index) => ({
row,
index: index + rowSlice.value.start,
}))
})
const leftOffset = computed(() => {
return colSlice.value.start > 0 ? colPositions.value[colSlice.value.start] - colPositions.value[1] : 0
})
const topOffset = computed(() => {
return rowHeightInPx[`${props.rowHeight}`] * rowSlice.value.start
})
// #Fill Handle
const fillHandleTop = ref()
@ -1085,12 +1248,18 @@ const fillHandleLeft = ref()
const refreshFillHandle = () => {
nextTick(() => {
const cellRef = document.querySelector('.last-cell')
if (cellRef) {
const cellRect = cellRef.getBoundingClientRect()
if (!cellRect || !gridWrapper.value) return
fillHandleTop.value = cellRect.top + cellRect.height - gridRect.top.value + gridWrapper.value.scrollTop
fillHandleLeft.value = cellRect.left + cellRect.width - gridRect.left.value + gridWrapper.value.scrollLeft
const rowIndex = isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row
const colIndex = isNaN(selectedRange.end.col) ? activeCell.col : selectedRange.end.col
if (rowIndex !== null && colIndex !== null) {
if (!scrollWrapper.value || !gridWrapper.value) return
// 32 for the header
fillHandleTop.value = (rowIndex + 1) * rowHeightInPx[`${props.rowHeight}`] + (hideHeader.value ? 0 : 32)
// 64 for the row number column
fillHandleLeft.value =
64 +
colPositions.value[colIndex + 1] +
(colIndex === 0 ? Math.max(0, scrollWrapper.value.scrollLeft - gridWrapper.value.offsetLeft) : 0)
}
})
}
@ -1115,6 +1284,7 @@ watch(
[() => selectedRange.end.row, () => selectedRange.end.col, () => activeCell.row, () => activeCell.col],
([sr, sc, ar, ac], [osr, osc, oar, oac]) => {
if (sr !== osr || sc !== osc || ar !== oar || ac !== oac) {
calculateSlices()
refreshFillHandle()
}
},
@ -1130,6 +1300,9 @@ onMounted(() => {
}
})
if (smartTable.value) resizeObserver.observe(smartTable.value)
until(scrollWrapper)
.toBeTruthy()
.then(() => calculateSlices())
})
// #Listeners
@ -1160,12 +1333,22 @@ async function reloadViewDataHandler(params: void | { shouldShowLoading?: boolea
await loadData?.({ ...(params?.offset !== undefined ? { offset: params.offset } : {}) })
calculateSlices()
isViewDataLoading.value = false
}
let frame: number | null = null
useEventListener(scrollWrapper, 'scroll', () => {
if (frame) {
cancelAnimationFrame(frame)
}
frame = requestAnimationFrame(() => {
calculateSlices()
refreshFillHandle()
})
})
useEventListener(document, 'mousedown', (e) => {
if (e.offsetX > (e.target as HTMLElement)?.clientWidth || e.offsetY > (e.target as HTMLElement)?.clientHeight) {
@ -1310,6 +1493,7 @@ watch(
isViewDataLoading.value = true
try {
await loadData?.()
calculateSlices()
} catch (e) {
if (!axios.isCancel(e)) {
console.log(e)
@ -1328,6 +1512,10 @@ watch(
{ immediate: true },
)
watch([() => fields.value.length, () => dataRef.value.length], () => {
calculateSlices()
})
// #Providers
provide(CellUrlDisableOverlayInj, disableUrlOverlay)
@ -1414,16 +1602,16 @@ onKeyStroke('ArrowDown', onDown)
:trigger="isSqlView ? [] : ['contextmenu']"
overlay-class-name="nc-dropdown-grid-context-menu"
>
<div class="table-overlay" :class="{ 'nc-grid-skeleton-loader': showSkeleton }">
<div>
<table
ref="smartTable"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white relative"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white sticky top-0 z-5 bg-white"
:class="{
'mobile': isMobileMode,
'desktop': !isMobileMode,
'pr-60 pb-12': !headerOnly,
mobile: isMobileMode,
desktop: !isMobileMode,
}"
:style="{
transform: `translateX(${leftOffset}px)`,
}"
@contextmenu="showContextMenu"
>
<thead v-show="hideHeader !== true" ref="tableHeadEl">
<tr v-if="isViewColumnsLoading">
@ -1446,7 +1634,13 @@ onKeyStroke('ArrowDown', onDown)
</td>
</tr>
<tr v-show="!isViewColumnsLoading" class="nc-grid-header">
<th class="w-[64px] min-w-[64px]" data-testid="grid-id-column" @dblclick="() => {}">
<th
class="w-[64px] min-w-[64px]"
data-testid="grid-id-column"
:style="{
left: `-${leftOffset}px`,
}"
>
<div class="w-full h-full flex pl-2 pr-1 items-center" data-testid="nc-check-all">
<template v-if="!readOnly">
<div class="nc-no-label text-gray-500" :class="{ hidden: vSelectedAllRecords }">#</div>
@ -1468,8 +1662,47 @@ onKeyStroke('ArrowDown', onDown)
</div>
</th>
<th
v-for="(col, index) in fields"
:key="col.title"
v-if="fields[0]"
v-xc-ver-resize
:data-col="fields[0].id"
:data-title="fields[0].title"
:style="{
'min-width': gridViewCols[fields[0].id]?.width || '180px',
'max-width': gridViewCols[fields[0].id]?.width || '180px',
'width': gridViewCols[fields[0].id]?.width || '180px',
...(leftOffset > 0
? {
left: `-${leftOffset - 64}px`,
}
: {}),
}"
class="nc-grid-column-header"
:class="{
'!border-r-blue-400 !border-r-3': toBeDroppedColId === fields[0].id,
}"
@xcstartresizing="onXcStartResizing(fields[0].id, $event)"
@xcresize="onresize(fields[0].id, $event)"
@xcresizing="onXcResizing(fields[0].id, $event)"
@click="selectColumn(fields[0].id!)"
>
<div
class="w-full h-full flex items-center text-gray-500 pl-2 pr-1"
draggable="false"
@dragstart.stop="onDragStart(fields[0].id!, $event)"
@drag.stop="onDrag($event)"
@dragend.stop="onDragEnd($event)"
>
<LazySmartsheetHeaderVirtualCell
v-if="fields[0] && colMeta[0].isVirtualCol"
:column="fields[0]"
:hide-menu="readOnly || isMobileMode"
/>
<LazySmartsheetHeaderCell v-else :column="fields[0]" :hide-menu="readOnly || isMobileMode" />
</div>
</th>
<th
v-for="{ field: col, index } in visibleFields"
:key="col.id"
v-xc-ver-resize
:data-col="col.id"
:data-title="col.title"
@ -1495,7 +1728,7 @@ onKeyStroke('ArrowDown', onDown)
@dragend.stop="onDragEnd($event)"
>
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(col)"
v-if="colMeta[index].isVirtualCol"
:column="col"
:hide-menu="readOnly || isMobileMode"
/>
@ -1623,6 +1856,29 @@ onKeyStroke('ArrowDown', onDown)
</th>
</tr>
</thead>
</table>
<div
class="table-overlay"
:class="{ 'nc-grid-skeleton-loader': showSkeleton }"
:style="{
height: `${maxGridHeight}px`,
width: `${maxGridWidth}px`,
}"
>
<table
ref="smartTable"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white relative"
:class="{
'mobile': isMobileMode,
'desktop': !isMobileMode,
'pr-60 pb-12': !headerOnly,
'w-full': dataRef.length === 0,
}"
:style="{
transform: `translateY(${topOffset}px) translateX(${leftOffset}px)`,
}"
@contextmenu="showContextMenu"
>
<tbody v-if="headerOnly !== true" ref="tableBodyEl">
<template v-if="showSkeleton">
<tr v-for="(row, rowIndex) of dummyRowDataForLoading" :key="rowIndex">
@ -1634,7 +1890,7 @@ onKeyStroke('ArrowDown', onDown)
></td>
</tr>
</template>
<LazySmartsheetRow v-for="(row, rowIndex) of dataRef" ref="rowRefs" :key="rowIndex" :row="row">
<LazySmartsheetRow v-for="{ row, index: rowIndex } in visibleData" :key="rowIndex" :row="row">
<template #default="{ state }">
<tr
v-show="!showSkeleton"
@ -1649,6 +1905,9 @@ onKeyStroke('ArrowDown', onDown)
<td
key="row-index"
class="caption nc-grid-cell w-[64px] min-w-[64px]"
:style="{
left: `-${leftOffset}px`,
}"
:data-testid="`cell-Id-${rowIndex}`"
@contextmenu="contextMenuTarget = null"
>
@ -1710,29 +1969,100 @@ onKeyStroke('ArrowDown', onDown)
</div>
</td>
<SmartsheetTableDataCell
v-for="(columnObj, colIndex) of fields"
:key="columnObj.id"
v-if="fields[0]"
:key="fields[0].id"
class="cell relative nc-grid-cell cursor-pointer"
:class="{
'active': isCellSelected(rowIndex, colIndex),
'active': selectRangeMap[`${rowIndex}-0`],
'active-cell':
(activeCell.row === rowIndex && activeCell.col === 0) ||
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === 0),
'nc-required-cell': cellMeta[rowIndex][0].isColumnRequiredAndNull && !isPublicView,
'align-middle': !rowHeight || rowHeight === 1,
'align-top': rowHeight && rowHeight !== 1,
'filling': fillRangeMap[`${rowIndex}-0`],
'readonly':
(colMeta[0].isLookup ||
colMeta[0].isRollup ||
colMeta[0].isFormula ||
colMeta[0].isCreatedOrLastModifiedTimeCol ||
colMeta[0].isCreatedOrLastModifiedByCol) &&
hasEditPermission &&
selectRangeMap[`${rowIndex}-0`],
'!border-r-blue-400 !border-r-3': toBeDroppedColId === fields[0].id,
}"
:style="{
'min-width': gridViewCols[fields[0].id]?.width || '180px',
'max-width': gridViewCols[fields[0].id]?.width || '180px',
'width': gridViewCols[fields[0].id]?.width || '180px',
...(leftOffset > 0
? {
left: `-${leftOffset - 64}px`,
}
: {}),
}"
:data-testid="`cell-${fields[0].title}-${rowIndex}`"
:data-key="`data-key-${rowIndex}-${fields[0].id}`"
:data-col="fields[0].id"
:data-title="fields[0].title"
:data-row-index="rowIndex"
:data-col-index="0"
@mousedown="handleMouseDown($event, rowIndex, 0)"
@mouseover="handleMouseOver($event, rowIndex, 0)"
@click="handleCellClick($event, rowIndex, 0)"
@dblclick="makeEditable(row, fields[0])"
@contextmenu="showContextMenu($event, { row: rowIndex, col: 0 })"
>
<div v-if="!switchingTab" class="w-full">
<LazySmartsheetVirtualCell
v-if="fields[0] && colMeta[0].isVirtualCol && fields[0].title"
v-model="row.row[fields[0].title]"
:column="fields[0]"
:active="activeCell.col === 0 && activeCell.row === rowIndex"
:row="row"
:read-only="!hasEditPermission"
@navigate="onNavigate"
@save="updateOrSaveRow?.(row, '', state)"
/>
<LazySmartsheetCell
v-else-if="fields[0] && fields[0].title"
v-model="row.row[fields[0].title]"
:column="fields[0]"
:edit-enabled="
!!hasEditPermission && !!editEnabled && activeCell.col === 0 && activeCell.row === rowIndex
"
:row-index="rowIndex"
:active="activeCell.col === 0 && activeCell.row === rowIndex"
:read-only="!hasEditPermission"
@update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow?.(row, fields[0].title, state)"
@navigate="onNavigate"
@cancel="editEnabled = false"
/>
</div>
</SmartsheetTableDataCell>
<SmartsheetTableDataCell
v-for="{ field: columnObj, index: colIndex } of visibleFields"
:key="`cell-${colIndex}-${rowIndex}`"
class="cell relative nc-grid-cell cursor-pointer"
:class="{
'active': selectRangeMap[`${rowIndex}-${colIndex}`],
'active-cell':
(activeCell.row === rowIndex && activeCell.col === colIndex) ||
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === colIndex),
'last-cell':
rowIndex === (isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) &&
colIndex === (isNaN(selectedRange.end.col) ? activeCell.col : selectedRange.end.col),
'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row) && !isPublicView,
'nc-required-cell': cellMeta[rowIndex][colIndex].isColumnRequiredAndNull && !isPublicView,
'align-middle': !rowHeight || rowHeight === 1,
'align-top': rowHeight && rowHeight !== 1,
'filling': isCellInFillRange(rowIndex, colIndex),
'filling': fillRangeMap[`${rowIndex}-${colIndex}`],
'readonly':
(isLookup(columnObj) ||
isRollup(columnObj) ||
isFormula(columnObj) ||
isCreatedOrLastModifiedTimeCol(columnObj) ||
isCreatedOrLastModifiedByCol(columnObj)) &&
(colMeta[colIndex].isLookup ||
colMeta[colIndex].isRollup ||
colMeta[colIndex].isFormula ||
colMeta[colIndex].isCreatedOrLastModifiedTimeCol ||
colMeta[colIndex].isCreatedOrLastModifiedByCol) &&
hasEditPermission &&
isCellSelected(rowIndex, colIndex),
selectRangeMap[`${rowIndex}-${colIndex}`],
'!border-r-blue-400 !border-r-3': toBeDroppedColId === columnObj.id,
}"
:style="{
@ -1754,7 +2084,7 @@ onKeyStroke('ArrowDown', onDown)
>
<div v-if="!switchingTab" class="w-full">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(columnObj) && columnObj.title"
v-if="colMeta[colIndex].isVirtualCol && columnObj.title"
v-model="row.row[columnObj.title]"
:column="columnObj"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
@ -1797,6 +2127,9 @@ onKeyStroke('ArrowDown', onDown)
>
<div
class="h-8 border-b-1 border-gray-100 bg-white group-hover:bg-gray-50 absolute left-0 bottom-0 px-2 sticky z-40 w-full flex items-center text-gray-500"
:style="{
left: `-${leftOffset}px`,
}"
>
<component
:is="iconMap.plus"
@ -1826,6 +2159,7 @@ onKeyStroke('ArrowDown', onDown)
}"
/>
</div>
</div>
<template #overlay>
<NcMenu class="!rounded !py-0" @click="contextMenu = false">
@ -1906,7 +2240,7 @@ onKeyStroke('ArrowDown', onDown)
contextMenuTarget &&
hasEditPermission &&
selectedRange.isSingleCell() &&
(isLinksOrLTAR(fields[contextMenuTarget.col]) || !isVirtualCol(fields[contextMenuTarget.col]))
(isLinksOrLTAR(fields[contextMenuTarget.col]) || !cellMeta[0]?.[contextMenuTarget.col].isVirtualCol)
"
class="nc-base-menu-item"
:disabled="isSystemColumn(fields[contextMenuTarget.col])"
@ -2365,11 +2699,6 @@ onKeyStroke('ArrowDown', onDown)
}
.nc-grid-header {
position: sticky;
top: -1px;
@apply z-5 bg-white;
&:hover {
.nc-no-label {
@apply hidden;

5
packages/nc-gui/components/tabs/Smartsheet.vue

@ -24,6 +24,7 @@ import {
useMetas,
useProvideCalendarViewStore,
useProvideKanbanViewStore,
useProvideSmartsheetLtarHelpers,
useProvideSmartsheetStore,
useRoles,
useSqlEditor,
@ -58,8 +59,6 @@ const { isGallery, isGrid, isForm, isKanban, isLocked, isMap, isCalendar, xWhere
useSqlEditor()
const { isPanelExpanded } = useExtensions()
const reloadViewDataEventHook = createEventHook()
const reloadViewMetaEventHook = createEventHook<void | boolean>()
@ -88,6 +87,8 @@ provide(
useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger())
useProvideViewGroupBy(activeView, meta, xWhere)
useProvideSmartsheetLtarHelpers(meta)
const grid = ref()
const onDrop = async (event: DragEvent) => {

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

@ -56,7 +56,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
: ({ row: {}, oldRow: {}, rowMeta: {} } as Row),
)
const rowStore = useProvideSmartsheetRowStore(meta, row)
const rowStore = useProvideSmartsheetRowStore(row)
const activeView = inject(ActiveViewInj, ref())

4
packages/nc-gui/composables/useMetas.ts

@ -3,7 +3,7 @@ import type { WatchStopHandle } from 'vue'
import type { TableType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, storeToRefs, useBase, useNuxtApp, useState, watch } from '#imports'
export function useMetas() {
export const useMetas = createSharedComposable(() => {
const { $api } = useNuxtApp()
const { tables: _tables } = storeToRefs(useBase())
@ -118,4 +118,4 @@ export function useMetas() {
}
return { getMeta, clearAllMeta, metas, metasWithIdAsKey, removeMeta, setMeta }
}
})

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

@ -335,26 +335,44 @@ export function useMultiSelect(
}
}
function isCellSelected(row: number, col: number) {
if (activeCell.col === col && activeCell.row === row) {
return true
}
const fillRangeMap = computed(() => {
/*
`${rowIndex}-${colIndex}`: true | false
*/
const map: Record<string, boolean> = {}
return selectedRange.isCellInRange({ row, col })
if (fillRange._start === null || fillRange._end === null) {
return map
}
function isCellInFillRange(row: number, col: number) {
if (fillRange._start === null || fillRange._end === null) {
return false
for (let row = fillRange.start.row; row <= fillRange.end.row; row++) {
for (let col = fillRange.start.col; col <= fillRange.end.col; col++) {
map[`${row}-${col}`] = true
}
}
if (selectedRange.isCellInRange({ row, col })) {
return false
return map
})
const selectRangeMap = computed(() => {
/*
`${rowIndex}-${colIndex}`: true | false
*/
const map: Record<string, boolean> = {}
if (selectedRange._start === null || selectedRange._end === null) {
return map
}
return fillRange.isCellInRange({ row, col })
for (let row = selectedRange.start.row; row <= selectedRange.end.row; row++) {
for (let col = selectedRange.start.col; col <= selectedRange.end.col; col++) {
map[`${row}-${col}`] = true
}
}
return map
})
const isPasteable = (row?: Row, col?: ColumnType, showInfo = false) => {
if (!row || !col) {
if (showInfo) {
@ -420,7 +438,7 @@ export function useMultiSelect(
// if there was a right click on selected range, don't restart the selection
if (
(event?.button !== MAIN_MOUSE_PRESSED || (event?.button === MAIN_MOUSE_PRESSED && event.ctrlKey)) &&
isCellSelected(row, col)
selectRangeMap.value[`${row}-${col}`]
) {
return
}
@ -489,7 +507,7 @@ export function useMultiSelect(
fillDirection === 1 ? row <= fillRange._end.row : row >= fillRange._end.row;
row += fillDirection
) {
if (isCellSelected(row, selectedRange.start.col)) {
if (selectRangeMap.value[`${row}-${selectedRange.start.col}`]) {
continue
}
@ -1298,14 +1316,14 @@ export function useMultiSelect(
handleMouseOver,
clearSelectedRange,
copyValue,
isCellSelected,
activeCell,
handleCellClick,
resetSelectedRange,
selectedRange,
makeActive,
isCellInFillRange,
isMouseDown,
isFillMode,
selectRangeMap,
fillRangeMap,
}
}

4
packages/nc-gui/composables/useSharedFormViewStore.ts

@ -1,7 +1,6 @@
import useVuelidate from '@vuelidate/core'
import { helpers, minLength, required, sameAs } from '@vuelidate/validators'
import dayjs from 'dayjs'
import type { Ref } from 'vue'
import type {
BoolType,
ColumnType,
@ -91,8 +90,9 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const preFilledDefaultValueformState = ref<Record<string, any>>({})
useProvideSmartsheetLtarHelpers(meta)
const { state: additionalState } = useProvideSmartsheetRowStore(
meta as Ref<TableType>,
ref({
row: formState,
rowMeta: { new: true },

249
packages/nc-gui/composables/useSmartsheetLtarHelpers.ts

@ -0,0 +1,249 @@
import { RelationTypes, isLinksOrLTAR } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import {
NOCO,
deepCompare,
extractPkFromRow,
extractSdkResponseErrorMsg,
isBt,
isHm,
isMm,
isOo,
message,
storeToRefs,
unref,
useBase,
useI18n,
useInjectionState,
useMetas,
useNuxtApp,
} from '#imports'
import type { Row } from '#imports'
const [useProvideSmartsheetLtarHelpers, useSmartsheetLtarHelpers] = useInjectionState(
(meta: Ref<TableType | undefined> | ComputedRef<TableType | undefined>) => {
const { $api } = useNuxtApp()
const { t } = useI18n()
const { base } = storeToRefs(useBase())
const { metas } = useMetas()
const getRowLtarHelpers = (row: Row) => {
if (!row.rowMeta) {
row.rowMeta = {}
}
if (!row.rowMeta.ltarState) {
row.rowMeta.ltarState = {}
}
return row.rowMeta.ltarState
}
// actions
const addLTARRef = async (row: Row, value: Record<string, any>, column: ColumnType) => {
if (isHm(column) || isMm(column)) {
if (!getRowLtarHelpers(row)[column.title!]) getRowLtarHelpers(row)[column.title!] = []
if (getRowLtarHelpers(row)[column.title!]!.find((ln: Record<string, any>) => deepCompare(ln, value))) {
// This value is already in the list
return message.info(t('msg.info.valueAlreadyInList'))
}
if (Array.isArray(value)) {
getRowLtarHelpers(row)[column.title!]!.push(...value)
} else {
getRowLtarHelpers(row)[column.title!]!.push(value)
}
} else if (isBt(column) || isOo(column)) {
getRowLtarHelpers(row)[column.title!] = value
}
}
// actions
const removeLTARRef = async (row: Row, value: Record<string, any>, column: ColumnType) => {
if (isHm(column) || isMm(column)) {
getRowLtarHelpers(row)[column.title!]?.splice(getRowLtarHelpers(row)[column.title!]?.indexOf(value), 1)
} else if (isBt(column) || isOo(column)) {
getRowLtarHelpers(row)[column.title!] = null
}
}
const linkRecord = async (
rowId: string,
relatedRowId: string,
column: ColumnType,
type: RelationTypes,
{ metaValue = meta.value }: { metaValue?: TableType } = {},
) => {
try {
await $api.dbTableRow.nestedAdd(
NOCO,
base.value.id as string,
metaValue?.id as string,
encodeURIComponent(rowId),
type,
column.id as string,
encodeURIComponent(relatedRowId),
)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
/** sync LTAR relations kept in local state */
const syncLTARRefs = async (
row: Row,
rowData: Record<string, any>,
{ metaValue = meta.value }: { metaValue?: TableType } = {},
) => {
const id = extractPkFromRow(rowData, metaValue?.columns as ColumnType[])
for (const column of metaValue?.columns ?? []) {
if (!isLinksOrLTAR(column)) continue
const colOptions = column.colOptions as LinkToAnotherRecordType
const relatedTableMeta = metas.value?.[colOptions?.fk_related_model_id as string]
if (isHm(column) || isMm(column)) {
const relatedRows = (getRowLtarHelpers(row)?.[column.title!] ?? []) as Record<string, any>[]
for (const relatedRow of relatedRows) {
await linkRecord(
id,
extractPkFromRow(relatedRow, relatedTableMeta.columns as ColumnType[]),
column,
colOptions.type as RelationTypes,
{ metaValue },
)
}
} else if ((isBt(column) || isOo(column)) && getRowLtarHelpers(row)?.[column.title!]) {
await linkRecord(
id,
extractPkFromRow(
getRowLtarHelpers(row)?.[column.title!] as Record<string, any>,
relatedTableMeta.columns as ColumnType[],
),
column,
colOptions.type as RelationTypes,
{ metaValue },
)
}
// clear LTAR refs after sync
getRowLtarHelpers(row)[column.title!] = null
}
}
// clear LTAR cell
const clearLTARCell = async (row: Row, column: ColumnType) => {
try {
if (!column || !isLinksOrLTAR(column)) return
const relatedTableMeta = metas.value?.[(<LinkToAnotherRecordType>column?.colOptions)?.fk_related_model_id as string]
if (row.rowMeta.new) {
getRowLtarHelpers(row)[column.title!] = null
} else {
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes((<LinkToAnotherRecordType>column.colOptions)?.type)) {
if (!row.row[column.title!]) return
await $api.dbTableRow.nestedRemove(
NOCO,
base.value.id as string,
meta.value?.id as string,
extractPkFromRow(row.row, meta.value?.columns as ColumnType[]),
(<LinkToAnotherRecordType>column.colOptions)?.type as any,
column.id as string,
extractPkFromRow(row.row[column.title!], relatedTableMeta?.columns as ColumnType[]),
)
row.row[column.title!] = null
} else {
for (const link of (row.row[column.title!] as Record<string, any>[]) || []) {
await $api.dbTableRow.nestedRemove(
NOCO,
base.value.id as string,
meta.value?.id as string,
encodeURIComponent(extractPkFromRow(row.row, meta.value?.columns as ColumnType[])),
(<LinkToAnotherRecordType>column?.colOptions).type as 'hm' | 'mm',
column.id as string,
encodeURIComponent(extractPkFromRow(link, relatedTableMeta?.columns as ColumnType[])),
)
}
row.row[column.title!] = []
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const loadRow = async (row: Row) => {
const record = await $api.dbTableRow.read(
NOCO,
base.value?.id as string,
meta.value?.title as string,
encodeURIComponent(extractPkFromRow(row.row, meta.value?.columns as ColumnType[])),
)
Object.assign(unref(row), {
row: record,
oldRow: { ...record },
rowMeta: {},
})
}
// clear MM cell
const cleaMMCell = async (row: Row, column: ColumnType) => {
try {
if (!column || !isLinksOrLTAR(column)) return
if (row.rowMeta.new) {
getRowLtarHelpers(row)[column.title!] = null
} else {
if ((<LinkToAnotherRecordType>column.colOptions)?.type === RelationTypes.MANY_TO_MANY) {
if (!row.row[column.title!]) return
const result = await $api.dbDataTableRow.nestedListCopyPasteOrDeleteAll(
meta.value?.id as string,
column.id as string,
[
{
operation: 'deleteAll',
rowId: extractPkFromRow(row.row, meta.value?.columns as ColumnType[]) as string,
columnId: column.id as string,
fk_related_model_id: (column.colOptions as LinkToAnotherRecordType)?.fk_related_model_id as string,
},
],
)
row.row[column.title!] = null
return Array.isArray(result.unlink) ? result.unlink : []
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
return {
addLTARRef,
removeLTARRef,
syncLTARRefs,
loadRow,
clearLTARCell,
cleaMMCell,
}
},
'smartsheet-ltar-helpers',
)
export { useProvideSmartsheetLtarHelpers }
export function useSmartsheetLtarHelpersOrThrow() {
const smartsheetLtarHelpers = useSmartsheetLtarHelpers()
if (smartsheetLtarHelpers == null) throw new Error('Please call `useSmartsheetLtarHelpers` on the appropriate parent component')
return smartsheetLtarHelpers
}

240
packages/nc-gui/composables/useSmartsheetRowStore.ts

@ -1,242 +1,42 @@
import { RelationTypes, isLinksOrLTAR } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import type { MaybeRef } from '@vueuse/core'
import {
NOCO,
computed,
deepCompare,
extractPkFromRow,
extractSdkResponseErrorMsg,
isBt,
isHm,
isMm,
isOo,
message,
ref,
storeToRefs,
unref,
useBase,
useI18n,
useInjectionState,
useMetas,
useNuxtApp,
} from '#imports'
import { computed, ref, unref, useInjectionState } from '#imports'
import type { Row } from '#imports'
const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
(meta: Ref<TableType | undefined>, row: MaybeRef<Row>) => {
const { $api } = useNuxtApp()
const { t } = useI18n()
const { base } = storeToRefs(useBase())
const { metas } = useMetas()
const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState((row: MaybeRef<Row>) => {
const currentRow = ref(row)
// state
const state = ref<Record<string, Record<string, any> | Record<string, any>[] | null>>({})
// getters
const isNew = computed(() => unref(row).rowMeta.new ?? false)
// actions
const addLTARRef = async (value: Record<string, any>, column: ColumnType) => {
if (isHm(column) || isMm(column)) {
if (!state.value[column.title!]) state.value[column.title!] = []
if (state.value[column.title!]!.find((ln: Record<string, any>) => deepCompare(ln, value))) {
// This value is already in the list
return message.info(t('msg.info.valueAlreadyInList'))
}
if (Array.isArray(value)) {
state.value[column.title!]!.push(...value)
} else {
state.value[column.title!]!.push(value)
}
} else if (isBt(column) || isOo(column)) {
state.value[column.title!] = value
}
}
// actions
const removeLTARRef = async (value: Record<string, any>, column: ColumnType) => {
if (isHm(column) || isMm(column)) {
state.value[column.title!]?.splice(state.value[column.title!]?.indexOf(value), 1)
} else if (isBt(column) || isOo(column)) {
state.value[column.title!] = null
}
}
const linkRecord = async (
rowId: string,
relatedRowId: string,
column: ColumnType,
type: RelationTypes,
{ metaValue = meta.value }: { metaValue?: TableType } = {},
) => {
try {
await $api.dbTableRow.nestedAdd(
NOCO,
base.value.id as string,
metaValue?.id as string,
encodeURIComponent(rowId),
type,
column.id as string,
encodeURIComponent(relatedRowId),
)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
/** sync LTAR relations kept in local state */
const syncLTARRefs = async (row: Record<string, any>, { metaValue = meta.value }: { metaValue?: TableType } = {}) => {
const id = extractPkFromRow(row, metaValue?.columns as ColumnType[])
for (const column of metaValue?.columns ?? []) {
if (!isLinksOrLTAR(column)) continue
const colOptions = column.colOptions as LinkToAnotherRecordType
const relatedTableMeta = metas.value?.[colOptions?.fk_related_model_id as string]
if (isHm(column) || isMm(column)) {
const relatedRows = (state.value?.[column.title!] ?? []) as Record<string, any>[]
for (const relatedRow of relatedRows) {
await linkRecord(
id,
extractPkFromRow(relatedRow, relatedTableMeta.columns as ColumnType[]),
column,
colOptions.type as RelationTypes,
{ metaValue },
)
const state = computed({
get: () => currentRow.value?.rowMeta?.ltarState ?? {},
set: (value) => {
if (currentRow.value) {
if (!currentRow.value.rowMeta) {
currentRow.value.rowMeta = {}
}
} else if ((isBt(column) || isOo(column)) && state.value?.[column.title!]) {
await linkRecord(
id,
extractPkFromRow(state.value?.[column.title!] as Record<string, any>, relatedTableMeta.columns as ColumnType[]),
column,
colOptions.type as RelationTypes,
{ metaValue },
)
currentRow.value.rowMeta.ltarState = value
}
// clear LTAR refs after sync
state.value[column.title!] = null
}
}
// clear LTAR cell
const clearLTARCell = async (column: ColumnType) => {
try {
if (!column || !isLinksOrLTAR(column)) return
const relatedTableMeta = metas.value?.[(<LinkToAnotherRecordType>column?.colOptions)?.fk_related_model_id as string]
if (isNew.value) {
state.value[column.title!] = null
} else if (currentRow.value) {
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes((<LinkToAnotherRecordType>column.colOptions)?.type)) {
if (!currentRow.value.row[column.title!]) return
await $api.dbTableRow.nestedRemove(
NOCO,
base.value.id as string,
meta.value?.id as string,
extractPkFromRow(currentRow.value.row, meta.value?.columns as ColumnType[]),
(<LinkToAnotherRecordType>column.colOptions)?.type as any,
column.id as string,
extractPkFromRow(currentRow.value.row[column.title!], relatedTableMeta?.columns as ColumnType[]),
)
currentRow.value.row[column.title!] = null
} else {
for (const link of (currentRow.value.row[column.title!] as Record<string, any>[]) || []) {
await $api.dbTableRow.nestedRemove(
NOCO,
base.value.id as string,
meta.value?.id as string,
encodeURIComponent(extractPkFromRow(currentRow.value.row, meta.value?.columns as ColumnType[])),
(<LinkToAnotherRecordType>column?.colOptions).type as 'hm' | 'mm',
column.id as string,
encodeURIComponent(extractPkFromRow(link, relatedTableMeta?.columns as ColumnType[])),
)
}
currentRow.value.row[column.title!] = []
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const loadRow = async () => {
const record = await $api.dbTableRow.read(
NOCO,
base.value?.id as string,
meta.value?.title as string,
encodeURIComponent(extractPkFromRow(unref(row)?.row, meta.value?.columns as ColumnType[])),
)
Object.assign(unref(row), {
row: record,
oldRow: { ...record },
rowMeta: {},
})
}
// clear MM cell
const cleaMMCell = async (column: ColumnType) => {
try {
if (!column || !isLinksOrLTAR(column)) return
if (isNew.value) {
state.value[column.title!] = null
} else if (currentRow.value) {
if ((<LinkToAnotherRecordType>column.colOptions)?.type === RelationTypes.MANY_TO_MANY) {
if (!currentRow.value.row[column.title!]) return
console.log('currentRow.value.row, meta.value?.columns', currentRow.value.row, meta.value?.columns)
const result = await $api.dbDataTableRow.nestedListCopyPasteOrDeleteAll(
meta.value?.id as string,
column.id as string,
[
{
operation: 'deleteAll',
rowId: extractPkFromRow(currentRow.value.row, meta.value?.columns as ColumnType[]) as string,
columnId: column.id as string,
fk_related_model_id: (column.colOptions as LinkToAnotherRecordType)?.fk_related_model_id as string,
},
],
)
})
currentRow.value.row[column.title!] = null
// getters
const isNew = computed(() => unref(row).rowMeta?.new ?? false)
return Array.isArray(result.unlink) ? result.unlink : []
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const { addLTARRef, removeLTARRef, syncLTARRefs, loadRow, clearLTARCell, cleaMMCell } = useSmartsheetLtarHelpersOrThrow()
return {
row,
state,
isNew,
// todo: use better name
addLTARRef,
removeLTARRef,
syncLTARRefs,
loadRow,
addLTARRef: (...args: any) => addLTARRef(currentRow.value, ...args),
removeLTARRef: (...args: any) => removeLTARRef(currentRow.value, ...args),
syncLTARRefs: (...args: any) => syncLTARRefs(currentRow.value, ...args),
loadRow: (...args: any) => loadRow(currentRow.value, ...args),
currentRow,
clearLTARCell,
cleaMMCell,
clearLTARCell: (...args: any) => clearLTARCell(currentRow.value, ...args),
cleaMMCell: (...args: any) => cleaMMCell(currentRow.value, ...args),
}
},
'smartsheet-row-store',
)
}, 'smartsheet-row-store')
export { useProvideSmartsheetRowStore }

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

@ -64,6 +64,7 @@ interface Row {
commentCount?: number
changed?: boolean
saving?: boolean
ltarState?: Record<string, Record<string, any> | Record<string, any>[] | null>
// use in datetime picker component
isUpdatedFromCopyNPaste?: Record<string, boolean>
// Used in Calendar view

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

@ -296,7 +296,8 @@ export class ColumnPageObject extends BasePage {
// when clicked on the dropdown cell header
await this.getColumnHeader(title).locator('.nc-ui-dt-dropdown').scrollIntoViewIfNeeded();
await this.getColumnHeader(title).locator('.nc-ui-dt-dropdown').click();
await this.rootPage.locator('li[role="menuitem"]:has-text("Edit")').last().click();
await expect(await this.rootPage.locator('li[role="menuitem"]:has-text("Edit"):visible').last()).toBeVisible();
await this.rootPage.locator('li[role="menuitem"]:has-text("Edit"):visible').last().click();
await this.get().waitFor({ state: 'visible' });

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

@ -79,6 +79,43 @@ export class GridPage extends BasePage {
return this.get().locator(`tr[data-testid="grid-row-${index}"]`);
}
async renderColumn(columnHeader: string) {
// we have virtual grid, so we need to make sure the column is rendered
const headerRow = this.get().locator('.nc-grid-header').first();
let column = headerRow.locator(`[data-title="${columnHeader}"]`);
let lastScrolledColumn: Locator = null;
let direction = 'right';
while (headerRow) {
try {
await column.elementHandle({ timeout: 1000 });
break;
} catch {}
const lastColumn =
direction === 'right'
? headerRow.locator('th.nc-grid-column-header').last()
: headerRow.locator('th.nc-grid-column-header').nth(1);
if (lastScrolledColumn) {
if ((await lastScrolledColumn.innerText()) === (await lastColumn.innerText())) {
if (direction === 'right') {
direction = 'left';
lastScrolledColumn = null;
} else {
throw new Error(`Column with header "${columnHeader}" not found`);
}
}
}
await lastColumn.scrollIntoViewIfNeeded();
lastScrolledColumn = lastColumn;
column = headerRow.locator(`[data-title="${columnHeader}"]`);
}
}
async rowCount() {
return await this.get().locator('.nc-grid-row').count();
}
@ -89,7 +126,8 @@ export class GridPage extends BasePage {
private async _fillRow({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) {
const cell = this.cell.get({ index, columnHeader });
await cell.waitFor({ state: 'visible' });
await expect(cell).toBeVisible();
await this.rootPage.waitForTimeout(500);
await this.cell.dblclick({
index,
columnHeader,
@ -132,7 +170,9 @@ export class GridPage extends BasePage {
// fallback
await this.rootPage.waitForTimeout(400);
await expect(this.get().locator('.nc-grid-row')).toHaveCount(rowCount);
await expect(this.get().locator(`[data-testid="grid-row-${rowCount - 1}"]`)).toHaveCount(1);
await this.rootPage.waitForLoadState('networkidle');
await this._fillRow({ index, columnHeader, value: rowValue });

2
tests/playwright/pages/Dashboard/common/Cell/UserOptionCell.ts

@ -100,7 +100,7 @@ export class UserOptionCellPageObject extends BasePage {
}
const locator = this.cell.get({ index, columnHeader }).locator('.ant-tag');
await locator.waitFor({ state: 'visible' });
await expect(locator).toBeVisible();
return expect(locator).toContainText(option);
}

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

@ -410,6 +410,7 @@ export class CellPageObject extends BasePage {
}
async copyCellToClipboard({ index, columnHeader }: CellProps, ...clickOptions: Parameters<Locator['click']>) {
if (this.parent instanceof GridPage) await this.parent.renderColumn(columnHeader);
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await this.get({ index, columnHeader }).click(...clickOptions);
await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable');

1
tests/playwright/pages/Dashboard/common/Topbar/index.ts

@ -82,5 +82,6 @@ export class TopbarPage extends BasePage {
async clickRefresh() {
await this.get().locator(`.nc-icon-reload`).waitFor({ state: 'visible' });
await this.get().locator(`.nc-icon-reload`).click();
await this.rootPage.waitForLoadState('networkidle');
}
}

6
tests/playwright/tests/db/columns/columnUserSelect.spec.ts

@ -214,9 +214,11 @@ test.describe('User single select', () => {
await grid.cell.click({ index: 3, columnHeader: 'User' });
await dashboard.rootPage.keyboard.press('Shift+ArrowDown');
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c');
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+C' : 'Control+C');
await grid.cell.click({ index: 0, columnHeader: 'User' });
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v');
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+V' : 'Control+V');
await dashboard.rootPage.waitForTimeout(500);
// refresh
await topbar.clickRefresh();

3
tests/playwright/tests/db/features/keyboardShortcuts.spec.ts

@ -272,6 +272,8 @@ test.describe('Clipboard support', () => {
// ########################################
await dashboard.grid.renderColumn('Attachment');
await dashboard.grid.cell.attachment.addFile({
index: 0,
columnHeader: 'Attachment',
@ -304,6 +306,7 @@ test.describe('Clipboard support', () => {
];
for (const { type, value } of responseTable) {
await dashboard.grid.renderColumn(type);
if (type === 'Rating') {
await dashboard.grid.cell.rating.verify({
index: rowIndex,

Loading…
Cancel
Save